fix(ollama): route models by advertised capability (#11088)

Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
This commit is contained in:
Praveen K Palaniswamy
2026-08-23 10:45:01 -04:00
committed by GitHub
parent c68cda7dfb
commit 65e81158ab
5094 changed files with 564668 additions and 80301 deletions

View File

@@ -1,26 +0,0 @@
import { execSync } from "child_process";
try {
console.log("Fetching workflow runs...");
const output = execSync("gh run list --limit 100 --json status,conclusion,databaseId", {
encoding: "utf8",
});
const runs = JSON.parse(output);
console.log(`Found ${runs.length} runs.`);
let count = 0;
for (const run of runs) {
if (run.conclusion !== "success") {
console.log(`Deleting run ID ${run.databaseId} with conclusion '${run.conclusion}'...`);
try {
execSync(`gh run delete ${run.databaseId}`);
count++;
} catch (err) {
console.error(`Failed to delete run ID ${run.databaseId}:`, err.message);
}
}
}
console.log(`Deleted ${count} runs successfully.`);
} catch (error) {
console.error("Error executing script:", error);
}

View File

@@ -0,0 +1,212 @@
[
{
"bucket": "A",
"match": "failed to load external module playwright",
"text": "That error means the Playwright install shipped with OmniRoute is broken, not that you misconfigured anything. Reinstall with npm i -g omniroute and run npx playwright install chromium on the same host. See open-sse/executors/gemini-web.ts."
},
{
"bucket": "A",
"match": "duckduckgo ai chat error",
"text": "That ERR_BAD_REQUEST usually means the model you picked is retired or unknown in Duck.ai's lineup, or a reasoningEffort setting the lineup doesn't accept. Try a current model like gpt-5.4-mini. See open-sse/executors/duckduckgo-web.ts."
},
{
"bucket": "A",
"match": "what does endpoints do",
"text": "Endpoints are the OpenAI-compatible surface OmniRoute exposes. You point any client at base http://localhost:20128/v1 with your API key and it behaves like a normal provider. For opencode there is a dedicated guide at docs/frameworks/OPENCODE.md."
},
{
"bucket": "A",
"match": "setup omniroute in opencode",
"text": "You don't need /connect. Run 'omniroute config opencode --base-url http://localhost:20128 --api-key YOUR_KEY' and point opencode at it. The most common bug is ending with /v1/v1, so keep a single /v1. See docs/frameworks/OPENCODE.md."
},
{
"bucket": "A",
"match": "best way to integrate jules",
"text": "Use the Cloud Agents API: POST /api/v1/agents/tasks with providerId jules and OmniRoute spins a remote agent for that task. Selection is manual per task and control is via REST or the dashboard. See docs/frameworks/CLOUD_AGENT.md."
},
{
"bucket": "A",
"match": "codex cloud and devin",
"text": "Same API, just swap the providerId: jules, devin, codex-cloud or cursor-cloud. Antigravity and Qwen are chat providers, not cloud agents, so they stay on chat routes. The choice is manual per task. See docs/frameworks/CLOUD_AGENT.md."
},
{
"bucket": "A",
"match": "handle everything from claude",
"text": "Not quite. Cloud agents are controlled through the REST API and the dashboard, not through Claude Code or MCP. So keep them as separate tooling that talks to OmniRoute. See docs/frameworks/CLOUD_AGENT.md."
},
{
"bucket": "A",
"match": "huggingchat returned http 500",
"text": "A 500 is a passthrough from the upstream HuggingChat endpoint (huggingface.co/chat), not something in your config. Just retry; if it keeps failing the service itself is likely having trouble. See open-sse/executors/huggingchat.ts."
},
{
"bucket": "A",
"match": "use this on termux",
"text": "In Termux run 'pkg install nodejs' and then 'npx -y omniroute' to start the server. Your phone browser opens the dashboard over localhost afterwards. Walkthrough at docs/guides/TERMUX_GUIDE.md."
},
{
"bucket": "A",
"match": "run the entire thing im on android",
"text": "You run everything in Termux with no root: pkg install nodejs, then npx -y omniroute starts the server. The dashboard opens in your phone's browser and all of it stays on the device."
},
{
"bucket": "A",
"match": "i dont have omniroute",
"text": "Quick start: npm i -g omniroute on any machine with Node. Start it, open http://localhost:20128, and the auto model already answers so you don't even need an API key to try it."
},
{
"bucket": "A",
"match": "api endpoints allowed",
"text": "Endpoints are their own API surface: anyone with a valid API key can call them. To lock it down, set REQUIRE_API_KEY=true so only the keys you issue get access. See docs/getting-started/QUICK-START.md."
},
{
"bucket": "A",
"match": "need which host",
"text": "The host is wherever you run the server, localhost:20128 by default. Clients just need the base URL (http://host:20128/v1) plus an API key, so a VPS or Fly instance works the same."
},
{
"bucket": "A",
"match": "hosting web in cpanel",
"text": "Self-host anywhere Node runs: a VPS, Docker or Fly.io. cPanel usually can't keep a long-running Node process alive, so prefer a real server or container. See docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md."
},
{
"bucket": "A",
"match": "run fly.io docker file",
"text": "Use the repo's fly.toml: fly launch and then fly deploy, and the Dockerfile builds the image. Full steps and env vars are in docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md."
},
{
"bucket": "A",
"match": "website https://fly.io",
"text": "Yes, the site runs on Fly.io, that's the host. From the repo run fly launch and fly deploy, and the app gets a public URL on a domain you own."
},
{
"bucket": "A",
"match": "github are down",
"text": "You don't need GitHub to run OmniRoute. It installs straight from npm and you host it anywhere you want, a VPS, Docker, or Fly. GitHub matters only if you build from source."
},
{
"bucket": "A",
"match": "2000 models is there a better way",
"text": "With that many models, Auto-Combo is the way: set the model to auto, auto/coding, auto/fast or auto/cheap and OmniRoute scores every option per request. The 14-factor scorer is in docs/routing/AUTO-COMBO.md."
},
{
"bucket": "A",
"match": "is there a combo already",
"text": "Yes, there is a ready one for exactly this: auto/coding. It picks a good free coding model with no setup. The other auto strategies are explained in docs/routing/AUTO-COMBO.md."
},
{
"bucket": "A",
"match": "where do i put auto",
"text": "You set it as the model field on your client exactly like a model name: auto/coding, or auto/fast and auto/cheap for other strategies. Their differences are in docs/routing/AUTO-COMBO.md."
},
{
"bucket": "A",
"match": "dont see my combos as models",
"text": "Only auto/ combos are advertised in /v1/models. Custom combos are internal destinations that never appear in the list, so call them directly by the combo id you set up."
},
{
"bucket": "A",
"match": "where is my circuit breaker",
"text": "It lives in the dashboard Health tab, in the circuit breaker states section, one status per provider. The closed, open, half-open model is in docs/architecture/RESILIENCE_GUIDE.md."
},
{
"bucket": "A",
"match": "with claude desktop app",
"text": "Two ways: Claude Code pointed at OmniRoute via ANTHROPIC_BASE_URL plus setup-claude, or the Claude Desktop app as an MCP client via omniroute --mcp. Both are in docs/guides/CLAUDE-CODE-CONFIGURATION.md."
},
{
"bucket": "A",
"match": "retrying in 30s",
"text": "That is a 429 rate limit from the provider, so retrying is expected. OmniRoute applies the cooldown and can fall back to another key or model automatically, so you don't need to touch anything."
},
{
"bucket": "A",
"match": "cliproxyapi is configured",
"text": "It is informative, not an error. CLIProxyAPI is an upstream proxy layer, managed at runtime in the CLI Tools and toggled per provider between native, cliproxyapi and fallback modes. See docs/ops/PROXY_GUIDE.md."
},
{
"bucket": "A",
"match": "getaddrinfo enotfound",
"text": "That is a doubled URL in the proxy registry: the host field carries the scheme. Use type=http, host=127.0.0.1 with no scheme, and port=20130. Steps are in docs/ops/PROXY_GUIDE.md."
},
{
"bucket": "A",
"match": "proxy connection failed",
"text": "The registry expects type, host and port as separate fields, not one combined URL. Set host to 127.0.0.1 with no scheme and port to 20130, and the connection error clears. Same recipe in docs/ops/PROXY_GUIDE.md."
},
{
"bucket": "B",
"match": "only 14 providers out of the 50",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "music play when i enable modal",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "do you mean the global proxy",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "provider's limits from docs",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "combine deepseek",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "store limits within the app",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "manually write these limits",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "official omniroute doesn't support",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "set limits in omniroute for a provider",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "create a compact prompt",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "continue your answer from where you left off",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "i am android that sorry",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "B",
"match": "yes",
"text": "I don't know this one yet - I've flagged it for someone who does, they'll get back to you soon :)"
},
{
"bucket": "C",
"match": "no such tool available: bash",
"text": "Here I only help with OmniRoute questions :)"
},
{
"bucket": "C",
"match": "interupt the code",
"text": "Here I only help with OmniRoute questions :)"
}
]

View File

@@ -0,0 +1,105 @@
/**
* Ad-hoc, one-shot dry run of STRICT_ZERO_COST against the real candidate
* pools currently served by this OmniRoute instance (fetched via the
* existing read-only `GET /v1/auto-combo/{channel}/candidates` endpoint —
* no changes made, no billable calls). Not wired into any test suite.
*
* Simulates the filter offline: no live usage-quota state is available
* (that adapter only runs inside the deployed container), so
* `resolveFreeAccessState` always returns `undefined` here — meaning any
* quota-based candidate is reported UNKNOWN unless it lacks even a usage
* adapter, in which case it's reported UNKNOWN for that reason instead. This
* intentionally shows the current, honest ceiling of what's usable today.
*
* Uses each candidate's REAL `connectionId` from the live endpoint (rather
* than assuming) to also exercise the post-code-review connection-safety
* check: a `keyless`-catalogued model whose live `connectionId` is NOT the
* no-auth sentinel is correctly reported as excluded here too.
*/
import { readFileSync } from "node:fs";
import {
evaluateCandidateConnections,
findBudgetEntry,
} from "../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage.ts";
const usageProviders = new Set<string>(USAGE_FETCHER_PROVIDERS);
const OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000 };
interface Candidate {
provider: string;
model: string;
connectionId: string;
}
function loadCandidates(path: string): Candidate[] {
const raw = JSON.parse(readFileSync(path, "utf8"));
const list = Array.isArray(raw) ? raw : raw.candidates;
// The candidates endpoint's `model` field is the FULL "<providerOrAlias>/<modelId>"
// string (`modelStr` — the leading segment is sometimes the provider id,
// e.g. "groq/...", sometimes its short alias, e.g. "oc/..." for opencode);
// FREE_MODEL_BUDGETS.modelId is always bare. Strip exactly the first "/"
// segment (whichever form it is) so e.g. "groq/meta-llama/llama-4-scout..."
// becomes "meta-llama/llama-4-scout..." and "oc/big-pickle" becomes
// "big-pickle", matching the catalog's modelId either way.
return list.map((c: { provider: string; model: string; connectionId?: string }) => {
const slash = c.model.indexOf("/");
return {
provider: c.provider,
model: slash === -1 ? c.model : c.model.slice(slash + 1),
connectionId: c.connectionId ?? SYNTHETIC_NOAUTH_CONNECTION_ID,
};
});
}
function run(label: string, path: string): void {
const candidates = loadCandidates(path);
console.log(`\n=== ${label}${candidates.length} candidati live ===`);
const kept: Candidate[] = [];
const excluded: { candidate: Candidate; reason: string }[] = [];
for (const c of candidates) {
const entry = findBudgetEntry(c);
if (!entry) {
excluded.push({ candidate: c, reason: "non presente nel catalogo free curato" });
continue;
}
const isNoAuthConnection = c.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
if (entry.freeType === "keyless") {
const safe = evaluateCandidateConnections(c, entry, () => undefined, OPTIONS);
if (safe.length > 0) {
kept.push(c);
} else if (!isNoAuthConnection) {
excluded.push({
candidate: c,
reason:
"keyless nel catalogo ma raggiunto tramite una connessione DB reale (non il sentinel noauth) — shortcut non applicato, richiederebbe hardStopGuaranteed",
});
} else {
excluded.push({ candidate: c, reason: "keyless ma valutazione fallita (inatteso)" });
}
continue;
}
const hasAdapter = usageProviders.has(entry.provider);
const reason = !hasAdapter
? `nessun usage adapter per '${entry.provider}' in USAGE_FETCHER_PROVIDERS`
: entry.hardStopGuaranteed !== true
? "hardStopGuaranteed non dichiarato per questo modello"
: "nessuno stato quota live disponibile in questo dry-run offline (richiederebbe il container reale)";
excluded.push({ candidate: c, reason });
}
console.log(`PRIMA (STRICT_ZERO_COST off): ${candidates.length} candidati`);
console.log(`DOPO (STRICT_ZERO_COST on): ${kept.length} candidati sopravvissuti`);
console.log("Sopravvissuti:");
for (const c of kept) console.log(` OK ${c.provider}/${c.model}`);
console.log("Esclusi (motivo):");
for (const { candidate: c, reason } of excluded) {
console.log(` EXCL ${c.provider}/${c.model}${reason}`);
}
}
run("auto/coding:free", process.argv[2] ?? "/tmp/dryrun_coding_free.json");
run("auto/best-free", process.argv[3] ?? "/tmp/dryrun_best-free.json");

View File

@@ -0,0 +1,52 @@
/**
* One-shot diagnostic: resolve every built-in auto-combo template and dump the
* resulting candidate pool, weight pack, and config as JSON for inspection.
*
* Run from repo root:
* node --import tsx/esm scripts/ad-hoc/dump-auto-combos.ts > _tasks/research/auto-combos-snapshot.json
*/
const { AUTO_TEMPLATE_VARIANTS, AUTO_SUFFIX_VARIANTS, AUTO_FAMILY_IDS } =
await import("@omniroute/open-sse/services/autoCombo/builtinCatalog");
const { createBuiltinAutoCombo, prepareBuiltinAutoComboInputs } =
await import("@omniroute/open-sse/services/autoCombo/builtinCatalog");
// Prepares the candidate pool once (DB reads: connections, settings, capabilities)
const prepared = await prepareBuiltinAutoComboInputs();
const allTemplates: string[] = [];
allTemplates.push(...Object.keys(AUTO_TEMPLATE_VARIANTS));
allTemplates.push(...AUTO_SUFFIX_VARIANTS);
allTemplates.push(...AUTO_FAMILY_IDS);
const results: Array<{
template: string;
candidateCount: number;
models: string[];
weightPack: Record<string, number>;
explorationRate: number;
}> = [];
for (const name of allTemplates) {
try {
const suffix = name.slice("auto/".length);
const combo = await createBuiltinAutoCombo(name, suffix, prepared as never);
results.push({
template: name,
candidateCount: combo.models.length,
models: combo.models.map((m) => m.model ?? `${m.providerId}/unknown`),
weightPack: combo.weights ?? {},
explorationRate: combo.explorationRate,
});
} catch (err) {
results.push({
template: name,
candidateCount: 0,
models: [],
weightPack: {},
explorationRate: 0,
});
}
}
console.log(JSON.stringify(results, null, 2));

View File

@@ -1,58 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
const REPO = "diegosouzapw/OmniRoute";
const artifactsDir =
process.env.ARTIFACTS_DIR ||
path.join(process.cwd(), "artifacts");
async function main() {
try {
// 1. Get PR numbers
console.log("Fetching open PR numbers...");
const prNumbersOutput = execSync(
`gh pr list --repo ${REPO} --state open --limit 500 --json number --jq '.[].number'`,
{ encoding: "utf-8" }
);
const prNumbers = prNumbersOutput.trim().split("\n").map(Number).filter(Boolean);
console.log(`Found ${prNumbers.length} open PRs:`, prNumbers);
if (!fs.existsSync(artifactsDir)) {
fs.mkdirSync(artifactsDir, { recursive: true });
}
// 2. Fetch metadata and diff for each PR
for (const prNum of prNumbers) {
console.log(`\n--- Fetching PR #${prNum} ---`);
// Metadata
try {
const metadataCmd = `gh pr view ${prNum} --repo ${REPO} --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`;
const metadataJson = execSync(metadataCmd, { encoding: "utf-8" });
const metadataPath = path.join(artifactsDir, `pr_${prNum}_meta.json`);
fs.writeFileSync(metadataPath, metadataJson);
console.log(`Saved metadata to ${metadataPath}`);
} catch (err) {
console.error(`Failed to fetch metadata for PR #${prNum}:`, err.message);
}
// Diff
try {
const diffCmd = `gh pr diff ${prNum} --repo ${REPO}`;
const diffText = execSync(diffCmd, { encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 });
const diffPath = path.join("/tmp", `pr${prNum}.diff`);
fs.writeFileSync(diffPath, diffText);
console.log(`Saved diff to ${diffPath} (Size: ${diffText.length} bytes)`);
} catch (err) {
console.error(`Failed to fetch diff for PR #${prNum}:`, err.message);
}
}
console.log("\nAll PR data fetched successfully!");
} catch (error) {
console.error("Error during PR fetching:", error);
}
}
main();

View File

@@ -0,0 +1,93 @@
// Runner generico para a mesh. Recebe um arquivo JSON de plano:
// [
// { "bucket": "A"|"B"|"C", "match": "<substring e lowercase do texto>", "text": "<resposta>" }
// ]
// Fases: A=reply (answered), B=notice mode:note (fica pending), C=recusa note + mark ignored (por ultimo).
// Envs: BOT_URL, BOT_TOKEN. Uso: node mesh-run.mjs <plano.json>
import { readFileSync } from "node:fs";
import { env } from "node:process";
const BOT_URL = env.BOT_URL;
const BOT_TOKEN = env.BOT_TOKEN;
const FILTER = "platform=discord&language=en&direct=only";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(path, opts = {}) {
const res = await fetch(BOT_URL + path, {
...opts,
headers: {
Authorization: "Bearer " + BOT_TOKEN,
"Content-Type": "application/json",
...(opts.headers || {}),
},
});
return { status: res.status, json: await res.json().catch(() => null) };
}
const planPath = process.argv[2];
const plan = JSON.parse(readFileSync(planPath, "utf8"));
async function main() {
console.log("Fetching pendentes (" + FILTER + ")...");
const { json } = await api("/internal/bridge/questions?" + FILTER);
const pending = (json && json.data) || [];
console.log("-> " + pending.length + " pendentes");
const out = { A: [], B: [], C: [], U: [] };
// fase 1-2: A (reply) e B (notice)
for (const msg of pending) {
const t = (msg.text || "").toLowerCase();
const hit = plan.find((e) => t.includes(e.match.toLowerCase()));
if (!hit) {
out.U.push(msg.id + " :: " + (msg.text || "").slice(0, 60));
continue;
}
if (hit.bucket === "A") {
const r = await api("/internal/bridge/reply", {
method: "POST",
body: JSON.stringify({ messageId: msg.id, text: hit.text }),
});
out.A.push(r.status + " " + msg.id);
} else if (hit.bucket === "B") {
const r = await api("/internal/bridge/reply", {
method: "POST",
body: JSON.stringify({ messageId: msg.id, text: hit.text, mode: "note" }),
});
out.B.push(r.status + " " + msg.id);
} else if (hit.bucket === "C") {
out.C.push(msg.id);
}
await sleep(1000);
}
// fase 3: bucket C — nota de recusa + mark ignored (por último)
for (const id of out.C) {
const msg = pending.find((m) => m.id === id);
const t = (msg.text || "").toLowerCase();
const hit = plan.find((e) => e.bucket === "C" && t.includes(e.match.toLowerCase()));
if (!hit) continue;
const note = await api("/internal/bridge/reply", {
method: "POST",
body: JSON.stringify({ messageId: id, text: hit.text, mode: "note" }),
});
const mark = await api("/internal/bridge/mark", {
method: "POST",
body: JSON.stringify({ messageIds: [id], status: "ignored", ref: "auto-declined" }),
});
out.C[out.C.indexOf(id)] =
"note:" + note.status + " mark:" + (mark.json && mark.json.updated) + " " + id;
await sleep(1000);
}
console.log("\n=== RESUMO ===");
console.log("A (respondidas):", out.A);
console.log("B (notices, pending):", out.B);
console.log("C (recusa+ignoradas):", out.C);
console.log("U (nao classif., relatar):", out.U);
}
main().catch((e) => {
console.error("ERRO:", e);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
// Helper único para enviar replies/notes no bridge do bot da mesh.
// Lê BOT_URL e BOT_TOKEN do ambiente (nunca embutidos).
// Uso: BOT_URL=... BOT_TOKEN=... node scripts/ad-hoc/mesh-send.mjs <cmd> <json-file|->
// cmd: reply | note
import { readFileSync } from "node:fs";
const [cmd, path] = process.argv.slice(2);
const BOT_URL = process.env.BOT_URL;
const BOT_TOKEN = process.env.BOT_TOKEN;
const input = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8");
const items = JSON.parse(input);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function send(item) {
// endpoint de reply; mode presente => note
const body = {
messageId: item.id,
text: item.text,
...(cmd === "note" ? { mode: "note" } : {}),
};
const res = await fetch(`${BOT_URL}/internal/bridge/reply`, {
method: "POST",
headers: {
Authorization: `Bearer ${BOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const txt = await res.text();
console.log(`[${cmd}] ${item.id.slice(0, 12)}${res.status} ${txt.slice(0, 80)}`);
}
for (const item of items) {
try {
await send(item);
} catch (e) {
console.log(`[${cmd}] ${item.id.slice(0, 12)} → ERRO ${e.message}`);
}
await sleep(1000); // pace ~1s
}

View File

@@ -1,280 +0,0 @@
import fs from "fs";
import { execSync } from "child_process";
import path from "path";
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
const filesToCheckoutOurs = [
".source/browser.ts",
".source/server.ts",
"package-lock.json",
"electron/package-lock.json",
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx",
"src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx",
"src/lib/db/contextHandoffs.ts",
"src/app/api/keys/groups/[id]/keys/route.ts",
"src/app/api/keys/groups/[id]/permissions/route.ts",
"src/app/api/keys/groups/[id]/route.ts",
"src/app/api/keys/groups/route.ts",
"src/app/api/middleware/hooks/[name]/route.ts",
"src/app/api/middleware/hooks/route.ts",
"src/app/api/relay/tokens/[id]/route.ts",
"src/app/api/relay/tokens/route.ts",
"src/app/api/playground/simulate-route/route.ts",
];
function runCmd(cmd) {
console.log(`Running: ${cmd}`);
return execSync(cmd, { cwd: projectRoot, encoding: "utf-8" });
}
async function main() {
// 1. Checkout ours for the files where HEAD is the preferred up-to-date state
for (const file of filesToCheckoutOurs) {
try {
runCmd(`git checkout --ours "${file}"`);
runCmd(`git add "${file}"`);
} catch (err) {
console.error(`Failed to checkout --ours for ${file}:`, err.message);
}
}
// 2. Resolve .dockerignore (keep release/v3.8.4 doc rules)
try {
runCmd("git checkout --theirs .dockerignore");
runCmd("git add .dockerignore");
} catch (err) {
console.error("Failed to resolve .dockerignore:", err.message);
}
// 3. Resolve docs/reference/ENVIRONMENT.md (keep release/v3.8.4 table formatting)
try {
runCmd("git checkout --theirs docs/reference/ENVIRONMENT.md");
runCmd("git add docs/reference/ENVIRONMENT.md");
} catch (err) {
console.error("Failed to resolve docs/reference/ENVIRONMENT.md:", err.message);
}
// 4. Resolve open-sse/executors/index.ts (keep both ClaudeWebExecutor and InnerAiExecutor)
const execIndexFile = path.join(projectRoot, "open-sse/executors/index.ts");
if (fs.existsSync(execIndexFile)) {
let content = fs.readFileSync(execIndexFile, "utf-8");
// Resolve imports conflict
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{ ClaudeWebExecutor \} from "\.\/claude-web\.ts";\r?\n=======\r?\nimport \{ InnerAiExecutor \} from "\.\/inner-ai\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { ClaudeWebExecutor } from "./claude-web.ts";\nimport { InnerAiExecutor } from "./inner-ai.ts";'
);
// Resolve executor registration conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+"claude-web": new ClaudeWebExecutor\(\),\r?\n\s+"cw-web": new ClaudeWebExecutor\(\), \/\/ Alias\r?\n=======\r?\n\s+"inner-ai": new InnerAiExecutor\(\),\r?\n\s+"in-ai": new InnerAiExecutor\(\), \/\/ Alias\r?\n>>>>>>> release\/v3\.8\.4/g,
' "claude-web": new ClaudeWebExecutor(),\n "cw-web": new ClaudeWebExecutor(), // Alias\n "inner-ai": new InnerAiExecutor(),\n "in-ai": new InnerAiExecutor(), // Alias'
);
fs.writeFileSync(execIndexFile, content);
runCmd("git add open-sse/executors/index.ts");
}
// 7. Resolve src/app/api/providers/[id]/models/route.ts (combine imports)
const modelsRoute = path.join(projectRoot, "src/app/api/providers/[id]/models/route.ts");
if (fs.existsSync(modelsRoute)) {
let content = fs.readFileSync(modelsRoute, "utf-8");
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error";\r?\nimport \{ getStaticQoderModels \} from "@omniroute\/open-sse\/services\/qoderCli\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";\nimport { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";'
);
fs.writeFileSync(modelsRoute, content);
runCmd("git add src/app/api/providers/[id]/models/route.ts");
}
// 8. Resolve src/sse/handlers/chat.ts
const sseChat = path.join(projectRoot, "src/sse/handlers/chat.ts");
if (fs.existsSync(sseChat)) {
let content = fs.readFileSync(sseChat, "utf-8");
// Resolve comment / modelStr conflict
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\n\s+\/\/ `let` because the middleware-hook pipeline \(line ~319\) may reassign this\r?\n\s+\/\/ when a hook rewrites the target model\. Previously declared `const`, which\r?\n\s+\/\/ broke turbopack\/strict-mode builds \(PR #2670 regression\)\.\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+let modelStr = body\.model;/g,
" // `let` because the middleware-hook pipeline (line ~319) may reassign this\n // when a hook rewrites the target model. Previously declared `const`, which\n // broke turbopack/strict-mode builds (PR [PR #2670](file:///home/diegosouzapw/dev/proxys/OmniRoute/package.json#L2670) regression).\n let modelStr = body.model;"
);
// Resolve trafficType / modelAbortSignal conflict (1st occurrence)
content = content.replace(
/<<<<<<< HEAD\r?\n\s+trafficType\?: "production" \| "shadow";\r?\n=======\r?\n\s+modelAbortSignal\?: AbortSignal \| null;\r?\n>>>>>>> release\/v3\.8\.4/g,
' trafficType?: "production" | "shadow";\n modelAbortSignal?: AbortSignal | null;'
);
fs.writeFileSync(sseChat, content);
runCmd("git add src/sse/handlers/chat.ts");
}
// 9. Resolve bin/cli/tray/autostart.mjs (keep execFileSync, combine ignoreFailure and systemd CI fallback)
const autostart = path.join(projectRoot, "bin/cli/tray/autostart.mjs");
if (fs.existsSync(autostart)) {
let content = fs.readFileSync(autostart, "utf-8");
// runUserSystemctl conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+\} catch \{\r?\n=======\r?\n\s+\} catch \(err\) \{\r?\n\s+if \(!ignoreFailure\) throw err;\r?\n>>>>>>> release\/v3\.8\.4/g,
` } catch (err) { \n if (!ignoreFailure) throw err;`
);
// isSystemdServiceEnabled conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+return false;\r?\n=======\r?\n\s+\/\/ systemctl --user can't query the bus \(headless environments \/ CI runners\)\.\r?\n\s+\/\/ Treat the presence of the unit file as the source of truth, matching the\r?\n\s+\/\/ fallback used in enableLinux\(\) where unit-file existence counts as success\.\r?\n\s+return true;\r?\n>>>>>>> release\/v3\.8\.4/g,
` // systemctl --user can't query the bus (headless environments / CI runners).\n // Treat the presence of the unit file as the source of truth, matching the\n // fallback used in enableLinux() where unit-file existence counts as success.\n return true;`
);
fs.writeFileSync(autostart, content);
runCmd("git add bin/cli/tray/autostart.mjs");
}
// 10. Resolve electron/package.json
const electronPkg = path.join(projectRoot, "electron/package.json");
if (fs.existsSync(electronPkg)) {
let content = fs.readFileSync(electronPkg, "utf-8");
content = content.replace(
/<<<<<<< HEAD\r?\n\s+"electron": "\^42\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.0"\r?\n=======\r?\n\s+"electron": "\^41\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.1"\r?\n>>>>>>> release\/v3\.8\.4/g,
' "electron": "^42.2.0",\n "electron-builder": "^26.11.1"'
);
fs.writeFileSync(electronPkg, content);
runCmd("git add electron/package.json");
}
// 11. Resolve .github/workflows/ci.yml
const ciYaml = path.join(projectRoot, ".github/workflows/ci.yml");
if (fs.existsSync(ciYaml)) {
let content = fs.readFileSync(ciYaml, "utf-8");
// Run c8 over shard title
content = content.replace(
/<<<<<<< HEAD\r?\n\s+rm -rf coverage-shard coverage-shard-report\r?\n=======\r?\n\s+# `--temp-directory` \(writable via NODE_V8_COVERAGE\) is what the merge\r?\n\s+# job reads with `c8 report --temp-directory \.\.\.`\. Using `--output-dir`\r?\n\s+# only produces the final json \*report\* and leaves the raw v8 files in\r?\n\s+# `coverage\/tmp`, so uploading `coverage-shard\/` was empty\. Pin the temp\r?\n\s+# dir so the raw coverage files live there and the artifact upload picks\r?\n\s+# them up regardless of `--test-force-exit` timing\.\r?\n>>>>>>> release\/v3\.8\.4/g,
" rm -rf coverage-shard coverage-shard-report\n # `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge\n # job reads with `c8 report --temp-directory ...`. Using `--output-dir`\n # only produces the final json *report* and leaves the raw v8 files in\n # `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp\n # dir so the raw coverage files live there and the artifact upload picks\n # them up regardless of `--test-force-exit` timing."
);
// c8 temp-directory arg
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\n\s+--temp-directory=coverage-shard\r?\n>>>>>>> release\/v3\.8\.4/g,
" --temp-directory=coverage-shard"
);
fs.writeFileSync(ciYaml, content);
runCmd("git add .github/workflows/ci.yml");
}
// 12. Resolve Dockerfile
const dockerfile = path.join(projectRoot, "Dockerfile");
if (fs.existsSync(dockerfile)) {
let content = fs.readFileSync(dockerfile, "utf-8");
// FROM node
content = content.replace(
/FROM node:26\.2\.0-trixie-slim AS builder\r?\nFROM node:24-trixie-slim AS builder/g,
"FROM node:24-trixie-slim AS builder"
);
// apt-get cache mounts
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/var\/cache\/apt,sharing=locked \\\r?\n\s+--mount=type=cache,target=\/var\/lib\/apt\/lists,sharing=locked \\\r?\n\s+apt-get update \\\r?\n=======\r?\nRUN apt-get update \\\r?\n>>>>>>> release\/v3\.8\.4/g,
"RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\\n apt-get update \\"
);
// npm ci script ignore and reproducible build check
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+if \[ -f package-lock\.json \]; then \\\r?\n\s+npm ci --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+else \\\r?\n\s+npm install --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+fi\r?\n=======\r?\n# `--ignore-scripts` blocks the install\/postinstall hooks of dependencies,[\s\S]*?RUN npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts\r?\n>>>>>>> release\/v3\.8\.4/g,
`# --ignore-scripts blocks the install/postinstall hooks of dependencies,
# closing the supply-chain attack surface where a transitive dep can run
# arbitrary code at install time. OmniRoute's own postinstall (
# better-sqlite3 binary touchups, @swc/helpers copy) is only needed when
# a packaged app/node_modules is unpacked — inside the Docker builder we
# are doing a fresh native-platform install, so dropping the scripts is safe.
#
# We REQUIRE a committed package-lock.json so resolved dependency versions
# are reproducible.
RUN test -f package-lock.json \\
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
RUN --mount=type=cache,target=/root/.npm \\
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts`
);
// npm global install
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n=======\r?\nRUN npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n\r?\nUSER node\r?\n\r?\n>>>>>>> release\/v3\.8\.4/g,
"RUN --mount=type=cache,target=/root/.npm \\\n npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest\n\nUSER node"
);
fs.writeFileSync(dockerfile, content);
runCmd("git add Dockerfile");
}
// 13. Resolve open-sse/services/combo.ts
const openSseCombo = path.join(projectRoot, "open-sse/services/combo.ts");
if (fs.existsSync(openSseCombo)) {
let content = fs.readFileSync(openSseCombo, "utf-8");
// IntentClassifierConfig imports
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{\r?\n\s+classifyWithConfig,\r?\n\s+DEFAULT_INTENT_CONFIG,\r?\n\s+type IntentClassifierConfig,\r?\n\} from "\.\/intentClassifier\.ts";\r?\n=======\r?\nimport \{ notifyWebhookEvent \} from "\.\.\/\.\.\/src\/lib\/webhookDispatcher";\r?\nimport \{ classifyWithConfig, DEFAULT_INTENT_CONFIG \} from "\.\/intentClassifier\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";\nimport {\n classifyWithConfig,\n DEFAULT_INTENT_CONFIG,\n type IntentClassifierConfig,\n} from "./intentClassifier.ts";'
);
// handlePipelineCombo call
content = content.replace(
/<<<<<<< HEAD\r?\n\s+handleChatCore: handleSingleModel,\r?\n\s+log: \{\r?\n\s+info: log\.info,\r?\n\s+warn: log\.warn,\r?\n\s+error: log\.error \?\? log\.warn,\r?\n\s+\},\r?\n\s+settings: settings \?\? \{\},\r?\n\s+signal: signal \?\? undefined,\r?\n=======\r?\n\s+handleChatCore: handleSingleModelWithTimeout,\r?\n\s+log,\r?\n\s+settings,\r?\n\s+signal,\r?\n>>>>>>> release\/v3\.8\.4/g,
" handleChatCore: handleSingleModelWithTimeout,\n log: {\n info: log.info,\n warn: log.warn,\n error: log.error ?? log.warn,\n },\n settings: settings ?? {},\n signal: signal ?? undefined,"
);
// handleSingleModel call in loop
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const result = await handleSingleModelWrapped\(attemptBody, modelStr, \{\r?\n=======\r?\n\s+const result = await handleSingleModelWithTimeout\(body, modelStr, \{\r?\n>>>>>>> release\/v3\.8\.4/g,
" const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {"
);
// recordSessionModelUsage conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+recordSessionModelUsage\([\s\S]*?\);\r?\n\s+\r?\n=======\r?\n>>>>>>> release\/v3\.8\.4/g,
" recordSessionModelUsage(\n relayOptions.sessionId,\n combo.name,\n modelStr,\n provider,\n target.connectionId ?? undefined\n );"
);
fs.writeFileSync(openSseCombo, content);
runCmd("git add open-sse/services/combo.ts");
}
// 14. Resolve src/app/api/copilot/chat/route.ts
const copilotChatRoute = path.join(projectRoot, "src/app/api/copilot/chat/route.ts");
if (fs.existsSync(copilotChatRoute)) {
let content = fs.readFileSync(copilotChatRoute, "utf-8");
// Imports conflict
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{ requireManagementAuth \} from "@\/lib\/api\/requireManagementAuth";\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport \{ isValidationFailure, validateBody \} from "@\/shared\/validation\/helpers";\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error\.ts";\r?\n=======\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport type \{ CopilotRequest \} from "@\/lib\/copilot\/engine";\r?\nimport \{ buildErrorBody \} from "@omniroute\/open-sse\/utils\/error";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { requireManagementAuth } from "@/lib/api/requireManagementAuth";\nimport { processCopilotChat } from "@/lib/copilot/engine";\nimport { isValidationFailure, validateBody } from "@/shared/validation/helpers";\nimport { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";'
);
// Schema content min length
content = content.replace(
/<<<<<<< HEAD\r?\n\s+content: z\.string\(\)\.min\(1, "message content is required"\),\r?\n=======\r?\n\s+content: z\.string\(\),\r?\n>>>>>>> release\/v3\.8\.4/g,
' content: z.string().min(1, "message content is required"),'
);
// POST implementation conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const authError = await requireManagementAuth\(request\);\r?\n\s+if \(authError\) return authError;\r?\n\r?\n\s+try \{\r?\n\s+const rawBody = await request.json\(\);\r?\n\s+const validation = validateBody\(copilotRequestSchema, rawBody\);\r?\n\s+if \(isValidationFailure\(validation\)\) \{\r?\n\s+return NextResponse\.json\(\{ error: validation\.error \}, \{ status: 400 \}\);\r?\n=======\r?\n\s+try \{\r?\n\s+const raw = await request.json\(\);\r?\n\s+const parsed = copilotRequestSchema\.safeParse\(raw\);\r?\n\s+if \(!parsed\.success\) \{\r?\n\s+return NextResponse\.json\r?\n\s+buildErrorBody\(400, parsed\.error\.issues\[0\]\?\.message \?\? "Invalid request"\),\r?\n\s+\{ status: 400 \}\r?\n\s+\);\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+\}\r?\n\s+const body = parsed\.data as CopilotRequest;\r?\n\r?\n\s+const response = await processCopilotChat\(body\);/g,
" const authError = await requireManagementAuth(request);\n if (authError) return authError;\n\n try {\n const rawBody = await request.json();\n const validation = validateBody(copilotRequestSchema, rawBody);\n if (isValidationFailure(validation)) {\n return NextResponse.json(\n buildErrorBody(400, validation.error),\n { status: 400 }\n );\n }\n const response = await processCopilotChat(validation.data);"
);
// Error handling conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const message = sanitizeErrorMessage\(error\);\r?\n\s+return NextResponse\.json\(\{ error: `Copilot error: \$\{message\}` \}, \{ status: 500 \}\);\r?\n=======\r?\n\s+\/\/ buildErrorBody\(\) routes through sanitizeErrorMessage\(\), which strips\r?\n\s+\/\/ stack traces and absolute file paths\. Hard rule #12\.\r?\n\s+const message = error instanceof Error \? error\.message : "Unknown error";\r?\n\s+return NextResponse\.json\(buildErrorBody\(500, message\), \{ status: 500 \}\);\r?\n>>>>>>> release\/v3\.8\.4/g,
" const message = sanitizeErrorMessage(error);\n return NextResponse.json(buildErrorBody(500, `Copilot error: ${message}`), { status: 500 });"
);
fs.writeFileSync(copilotChatRoute, content);
runCmd("git add src/app/api/copilot/chat/route.ts");
}
console.log("Resolutions written and staged!");
}
main();

View File

@@ -1,12 +1,11 @@
#!/usr/bin/env node
// Sync the cursor models list in open-sse/config/providerRegistry.ts from
// cursor-agent's runtime model list. Triggers an intentional invalid --model
// invocation so cursor-agent prints "Available models: ..." on stderr.
// Sync the cursor models list in open-sse/config/providers/registry/cursor/index.ts
// from cursor-agent's runtime model list (`--list-models`).
//
// Usage:
// node scripts/ad-hoc/sync-cursor-models.mjs # spawn cursor-agent and apply
// node scripts/ad-hoc/sync-cursor-models.mjs --dry-run # print proposed block, don't write
// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read the error message from stdin
// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read --list-models output from stdin
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
@@ -14,7 +13,17 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts");
const REGISTRY_PATH = resolve(
__dirname,
"..",
"..",
"open-sse",
"config",
"providers",
"registry",
"cursor",
"index.ts"
);
const args = new Set(process.argv.slice(2));
const DRY_RUN = args.has("--dry-run");

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* One-shot, narrowly-scoped i18n sync for PR #10603.
*
* The full `i18n:sync-ui` tool syncs every missing key against en.json (which
* also picks up an unrelated pre-existing ~33-key backlog per locale). This
* PR only added 11 new keys under `providers.` (autoFetchModels-prefixed,
* overridesUpstreamModel-prefixed, resetToUpstreamDefaults-prefixed), so
* this script translates and inserts only those 11 keys into every locale
* file that is missing them, leaving everything else in each locale file
* byte-identical. Reuses the same translation backend env vars as
* scripts/i18n/sync-ui-keys.mjs (OMNIROUTE_TRANSLATION_API_URL/KEY/MODEL).
*
* Usage: node scripts/ad-hoc/sync-provider-auto-fetch-i18n-keys.mjs
*/
import { promises as fs, existsSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const CONFIG_PATH = path.join(ROOT, "config", "i18n.json");
const ENV_PATH = path.join(ROOT, ".env");
const TARGET_KEYS = [
"autoFetchModels",
"autoFetchModelsTooltip",
"autoFetchModelsEnabled",
"autoFetchModelsDisabled",
"autoFetchModelsToggleFailed",
"autoFetchModelsPartialFailure",
"overridesUpstreamModel",
"overridesUpstreamModelHint",
"resetToUpstreamDefaults",
"resetToUpstreamDefaultsSuccess",
"resetToUpstreamDefaultsFailed",
];
const NAMESPACE = "providers";
function loadDotEnv() {
if (!existsSync(ENV_PATH)) return;
const content = readFileSync(ENV_PATH, "utf8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (!key || process.env[key] !== undefined) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
function requireEnv(name) {
const v = process.env[name];
if (!v || !v.trim()) {
throw new Error(`Missing required env var: ${name}`);
}
return v.trim();
}
function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, messages, temperature: 0.15, stream: false }),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 2) {
const wait = 1500 + retry * 1500;
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) throw new Error("upstream returned empty content");
return content;
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 2) return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
throw new Error(`timeout after ${timeoutMs}ms`);
}
if (retry < 2) {
await new Promise((r) => setTimeout(r, 1500));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw err;
} finally {
clearTimeout(timer);
}
}
const TRANSLATION_SYSTEM = (englishName, native) =>
[
`You are a professional translator for technical software UI strings.`,
`Translate the user's English UI string into ${englishName} (native: ${native}).`,
`Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`,
`Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`,
`Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`,
`Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`,
`Keep punctuation and trailing whitespace identical to the source.`,
].join(" ");
async function translateString(englishValue, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const messages = [
{ role: "system", content: TRANSLATION_SYSTEM(englishName, native) },
{ role: "user", content: englishValue },
];
const out = await callChat(messages, backend);
return out.trim();
}
function createLimiter(max) {
let active = 0;
const queue = [];
const next = () => {
if (!queue.length || active >= max) return;
active++;
const { fn, resolve, reject } = queue.shift();
fn()
.then((v) => {
active--;
resolve(v);
next();
})
.catch((err) => {
active--;
reject(err);
next();
});
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
async function main() {
loadDotEnv();
const backend = backendConfig();
const config = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8"));
const en = JSON.parse(await fs.readFile(path.join(MESSAGES_DIR, "en.json"), "utf8"));
const englishValues = Object.fromEntries(TARGET_KEYS.map((k) => [k, en[NAMESPACE][k]]));
for (const [k, v] of Object.entries(englishValues)) {
if (typeof v !== "string") throw new Error(`en.json is missing providers.${k}`);
}
const onDisk = new Set(
(await fs.readdir(MESSAGES_DIR)).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5))
);
const targetLocales = config.locales
.map((l) => l.code)
.filter((code) => code !== "en" && onDisk.has(code));
const limit = createLimiter(Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4));
let filesChanged = 0;
let keysAdded = 0;
for (const code of targetLocales) {
const localeEntry = config.locales.find((l) => l.code === code);
const localePath = path.join(MESSAGES_DIR, `${code}.json`);
const target = JSON.parse(await fs.readFile(localePath, "utf8"));
if (!target[NAMESPACE] || typeof target[NAMESPACE] !== "object") {
throw new Error(`${code}.json has no "providers" namespace object`);
}
const missingKeys = TARGET_KEYS.filter(
(k) => typeof target[NAMESPACE][k] !== "string" || target[NAMESPACE][k].length === 0
);
if (missingKeys.length === 0) {
console.log(`[sync-provider-i18n] ${code}: already has all 11 keys — skipping`);
continue;
}
await Promise.all(
missingKeys.map((k) =>
limit(async () => {
const translated = await translateString(englishValues[k], localeEntry, backend);
target[NAMESPACE][k] = translated;
})
)
);
await fs.writeFile(localePath, JSON.stringify(target, null, 2) + "\n", "utf8");
filesChanged++;
keysAdded += missingKeys.length;
console.log(`[sync-provider-i18n] ${code}: added ${missingKeys.length} keys`);
}
console.log(`[sync-provider-i18n] done: ${filesChanged} files changed, ${keysAdded} keys added`);
}
main().catch((err) => {
console.error("[sync-provider-i18n] FAILED:", err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,36 @@
// Verificacao de cobertura do plano da mesh.
// Envs: BOT_URL, BOT_TOKEN. Uso: node verify-coverage.mjs <plano.json>
import { readFileSync } from "node:fs";
import { env } from "node:process";
const BOT_URL = env.BOT_URL;
const BOT_TOKEN = env.BOT_TOKEN;
const FILTER = "platform=discord&language=en&direct=only";
const plan = JSON.parse(readFileSync(process.argv[2], "utf8"));
const res = await fetch(BOT_URL + "/internal/bridge/questions?" + FILTER, {
headers: { Authorization: "Bearer " + BOT_TOKEN },
});
const json = await res.json();
const pending = json.data || [];
const gaps = [];
const amb = [];
for (const m of pending) {
const t = (m.text || "").toLowerCase();
const hits = plan.filter((e) => t.includes(e.match.toLowerCase()));
if (hits.length === 0) {
gaps.push(m.id + " :: " + t.slice(0, 80));
} else if (hits.length > 1) {
const names = hits.map((h) => h.bucket + ":" + h.match).join(" | ");
amb.push(m.id + " :: " + names + " :: " + t.slice(0, 50));
}
}
console.log("pendentes:", pending.length, "| plano:", plan.length);
console.log("\n[GAPS] sem match (" + gaps.length + "):");
for (const g of gaps) console.log(" -", g);
console.log("\n[AMB] >1 match (" + amb.length + "):");
for (const a of amb) console.log(" -", a);

View File

@@ -10,7 +10,7 @@
* .next/standalone -> outDir (cp) Y Y Y SHARED
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
* public/ -> outDir/public/ (cp) Y Y Y SHARED
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
* wreq-js -> outDir/node_modules/wreq-js Y Y Y SHARED (extra module)
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
@@ -39,7 +39,7 @@
* prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish)
* data/ dir creation - Y - UNIQUE (prepublish)
* --- electron-UNIQUE ---
* better-sqlite3 native strip + Electron-ABI rebuild - - Y UNIQUE (electron)
* better-sqlite3 prebuild verify + compile-input strip - - Y UNIQUE (electron)
* Turbopack hashed-module symlink materialize (node_modules) - - Y SHARED (opt-in: materializeSymlinks)
* symlink guard (assertBundleIsPackagable) - - Y UNIQUE (electron)
* removeGeneratedElectronArtifacts - - Y UNIQUE (electron)
@@ -48,6 +48,7 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs";
/**
* Check whether a path exists (async).
@@ -74,17 +75,30 @@ async function exists(targetPath) {
* (relative to projectRoot) and destination (relative to outDir) can be joined
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
*/
const NATIVE_ASSET_ENTRIES = [
{
label: "wreq-js native runtime",
src: ["node_modules", "wreq-js", "rust"],
dest: ["node_modules", "wreq-js", "rust"],
},
export const NATIVE_ASSET_ENTRIES = [
{
label: "better-sqlite3 native binary",
src: ["node_modules", "better-sqlite3", "build"],
dest: ["node_modules", "better-sqlite3", "build"],
},
{
label: "better-sqlite3 prebuilt native binaries",
src: ["node_modules", "better-sqlite3", "prebuilds"],
dest: ["node_modules", "better-sqlite3", "prebuilds"],
},
{
// onnxruntime-node's dist/binding.js dlopen()s a platform-specific
// libonnxruntime.so.1 shipped under bin/napi-v3/<platform>/<arch>/ — a
// *dynamic* native load Next.js's standalone file trace can't see (same
// blind spot class as the LLMLingua closure below, just for a .so instead
// of a JS import). Without this the standalone bundle boots with
// "Error: libonnxruntime.so.1: cannot open shared object file: No such
// file or directory" the first time transformers/llmlingua actually try
// to run ONNX inference.
label: "onnxruntime-node native binaries (libonnxruntime .so + .node addon)",
src: ["node_modules", "onnxruntime-node", "bin"],
dest: ["node_modules", "onnxruntime-node", "bin"],
},
{
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
@@ -98,6 +112,15 @@ const NATIVE_ASSET_ENTRIES = [
/** @type {{label:string, src:string[], dest:string[]}[]} */
const EXTRA_MODULE_ENTRIES = [
{
// tlsClient.ts intentionally resolves wreq-js through a runtime-dynamic
// require so Turbopack cannot rewrite the package name to a hashed external.
// That also makes the package invisible to static tracing, so copy the whole
// module—not only rust/—into every standalone artifact.
label: "wreq-js TLS runtime",
src: ["node_modules", "wreq-js"],
dest: ["node_modules", "wreq-js"],
},
{
label: "@swc/helpers",
src: ["node_modules", "@swc", "helpers"],
@@ -116,6 +139,25 @@ const EXTRA_MODULE_ENTRIES = [
{ label: "split2", src: ["node_modules", "split2"], dest: ["node_modules", "split2"] },
{ label: "migrations", src: ["src", "lib", "db", "migrations"], dest: ["migrations"] },
{ label: "MITM server", src: ["src", "mitm", "server.cjs"], dest: ["src", "mitm", "server.cjs"] },
{
// #9451: server.cjs requires 6 shims from ./_internal/ (bypass, ingest,
// forwardTarget, aliasConfig, standaloneRouting, rootCaShim) which the MITM
// child process loads via require(). Next.js's standalone tracer never sees
// them (server.cjs is a separate node process, not imported by the main
// server), so the _internal/ directory must be copied explicitly or the MITM
// child crashes with MODULE_NOT_FOUND at boot.
label: "MITM _internal shims (#9451)",
src: ["src", "mitm", "_internal"],
dest: ["src", "mitm", "_internal"],
},
{
// #9451: rootCaShim.cjs does `await import("selfsigned")` for dynamic SSL
// certificate generation. The MITM child is not traced by Next.js, so the
// package is absent from the Docker standalone bundle without this entry.
label: "selfsigned (MITM rootCaShim dynamic import — #9451)",
src: ["node_modules", "selfsigned"],
dest: ["node_modules", "selfsigned"],
},
{
label: "run-standalone script",
src: ["scripts", "dev", "run-standalone.mjs"],
@@ -141,6 +183,11 @@ const EXTRA_MODULE_ENTRIES = [
src: ["scripts", "dev", "main-server-timeouts.mjs"],
dest: ["main-server-timeouts.mjs"],
},
{
label: "systemd sd_notify helper (server-ws.mjs dependency)",
src: ["scripts", "dev", "systemd-notify.mjs"],
dest: ["systemd-notify.mjs"],
},
{
label: "HTTP method guard (server-ws.mjs dependency)",
src: ["scripts", "dev", "http-method-guard.cjs"],
@@ -156,6 +203,11 @@ const EXTRA_MODULE_ENTRIES = [
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
dest: ["responses-ws-proxy.mjs"],
},
{
label: "ChatGPT Web Codex MCP tunnel entrypoint",
src: ["bin", "chatgpt-web-codex-mcp.mjs"],
dest: ["bin", "chatgpt-web-codex-mcp.mjs"],
},
{
label: "webdav-handler (server-ws.mjs dependency)",
src: ["scripts", "dev", "webdav-handler.mjs"],
@@ -214,6 +266,21 @@ const EXTRA_MODULE_ENTRIES = [
src: ["node_modules", "undici"],
dest: ["node_modules", "undici"],
},
{
// Turbopack's standalone tracer can emit a hollow node_modules/ws/ directory
// for the externalized `ws` package (no package.json / index.js), which then
// shadows the real install at runtime and crashes instrumentation with:
// "Cannot find package '<bundle>/node_modules/ws/index.js'" (#OmniRoute v3.8.50 live bug).
// Overlay the full source package so the bundled server resolves the real entrypoint.
label: "ws (externalized runtime package shadow fix)",
src: ["node_modules", "ws"],
dest: ["node_modules", "ws"],
},
{
label: "sql.js WASM fallback runtime",
src: ["node_modules", "sql.js"],
dest: ["node_modules", "sql.js"],
},
{
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
src: ["node_modules", "sqlite-vec"],
@@ -235,7 +302,7 @@ const EXTRA_MODULE_ENTRIES = [
];
/**
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
* Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY).
*
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
* for backward compatibility with existing callers and tests.
@@ -285,6 +352,11 @@ async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;
const destinationPath = path.join(outDir, ...entry.dest);
// See resolvesToSamePath/clearStaleDest (sync copy path, same module) — the same
// ERR_FS_CP_EINVAL/ERR_FS_CP_DIR_TO_NON_DIR races apply to fsImpl.cp here.
if (resolvesToSamePath(sourcePath, destinationPath)) continue;
clearStaleDest(destinationPath);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
@@ -321,6 +393,9 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;
const destPath = path.join(outDir, ...entry.dest);
if (resolvesToSamePath(sourcePath, destPath)) continue;
clearStaleDest(destPath);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destPath), { recursive: true });
@@ -469,8 +544,48 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir
}
/**
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
* Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree
* copy (step 1 of assembleStandalone) can already have carried a prior entry's result
* into `dest` (e.g. an absolute pnpm-store symlink, or a directory) BEFORE this entry's
* own copy runs. `fs.cpSync`/`fs.cp` refuse to overwrite in two such cases even with
* `force: true`:
* - dest already resolves (via symlink chain) to the exact same real path as src ->
* ERR_FS_CP_EINVAL "src and dest cannot be the same".
* - dest exists with a different node type than src (file/symlink vs directory) ->
* ERR_FS_CP_DIR_TO_NON_DIR / ERR_FS_CP_NON_DIR_TO_DIR.
* Under heavy concurrent build I/O this manifested non-deterministically across
* different EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES on every retry. Resolve both
* cases up front: skip entirely when dest is already the right target, otherwise clear
* whatever stale node occupies dest (via lstat, so it also removes a broken symlink)
* so the fresh copy always lands cleanly.
*
* @param {string} src
* @param {string} dest
* @returns {boolean} true when dest already IS src's target and no copy is needed
*/
function resolvesToSamePath(src, dest) {
if (path.resolve(src) === path.resolve(dest)) return true;
if (!fsSync.existsSync(dest)) return false;
try {
return fsSync.realpathSync(src) === fsSync.realpathSync(dest);
} catch {
return false;
}
}
/** @see resolvesToSamePath — clears whatever stale node sits at `dest` before a copy. */
function clearStaleDest(dest) {
try {
fsSync.lstatSync(dest);
} catch {
return;
}
fsSync.rmSync(dest, { recursive: true, force: true });
}
/**
* Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars
* (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
* into the assembled bundle. Missing sources are skipped silently.
*
* @param {string} projectRoot
@@ -481,6 +596,8 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...asset.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...asset.dest);
if (resolvesToSamePath(src, dest)) continue;
clearStaleDest(dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
@@ -490,12 +607,81 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...mod.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...mod.dest);
if (resolvesToSamePath(src, dest)) continue;
clearStaleDest(dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
}
}
/**
* Next/Turbopack standalone output can leave behind hollow top-level package
* directories for externalized runtime deps (directory exists, but contains no
* files). Those empty placeholders shadow the real repo-level install and make
* runtime ESM externals fail with "Cannot find package '<bundle>/node_modules/<pkg>/index.js'"
* even though the dependency is present in the source tree.
*
* Repair strategy: for each empty top-level package dir already present in the
* assembled bundle, if the same package exists in the project root node_modules,
* replace the hollow directory with a full recursive copy from the source install.
* This keeps the fix narrowly scoped to packages the standalone already expects.
*
* @param {string} projectRoot
* @param {string} bundleNodeModules
* @returns {{repaired: number, packages: string[]}}
*/
function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) {
const summary = { repaired: 0, packages: [] };
const sourceNodeModules = path.join(projectRoot, "node_modules");
if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) {
return summary;
}
for (const name of fsSync.readdirSync(bundleNodeModules)) {
if (name.startsWith(".") || name.startsWith("@")) continue;
const bundlePkgDir = path.join(bundleNodeModules, name);
const sourcePkgDir = path.join(sourceNodeModules, name);
let bundleStat;
try {
bundleStat = fsSync.statSync(bundlePkgDir);
} catch {
continue;
}
if (!bundleStat.isDirectory()) continue;
let bundleEntries = [];
try {
bundleEntries = fsSync.readdirSync(bundlePkgDir);
} catch {
continue;
}
if (bundleEntries.length > 0 || !fsSync.existsSync(sourcePkgDir)) continue;
let sourceStat;
try {
sourceStat = fsSync.statSync(sourcePkgDir);
} catch {
continue;
}
if (!sourceStat.isDirectory()) continue;
// See resolvesToSamePath/clearStaleDest above: bundlePkgDir can itself be a
// symlink to sourcePkgDir's realpath whose target momentarily read as empty
// under heavy concurrent build I/O (a transient readdirSync race, not a real
// hollow placeholder), or a stale non-directory node from an earlier pass.
if (resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue;
clearStaleDest(bundlePkgDir);
fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true });
summary.repaired += 1;
summary.packages.push(name);
}
return summary;
}
/**
* Materialize Turbopack "hashed external module" symlinks inside a bundled
* node_modules dir into real, self-contained directories.
@@ -712,6 +898,36 @@ export function assembleStandalone({
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
// Repair hollow externalized package dirs in BOTH locations Turbopack's standalone
// tracer can populate: the top-level bundle node_modules, and — for projects with a
// custom distDir (see next.config.mjs) — the nested <relDistDir>/node_modules mirrored
// alongside the traced server chunks. materializeBundledSymlinks (step 7 below) already
// treats these as two distinct targets; #9913 only covered the top-level one, which left
// the nested location's hollow dirs unrepaired (#7346).
for (const bundleNodeModules of [
path.join(resolvedOutDir, "node_modules"),
path.join(resolvedOutDir, relDistDir, "node_modules"),
]) {
const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules);
if (emptyPkgRepair.repaired > 0) {
console.log(
`[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s) in ` +
`${path.relative(resolvedOutDir, bundleNodeModules) || "."}: ${emptyPkgRepair.packages.join(", ")}`
);
}
}
// #9166: dynamically imported LLMLingua packages are not reliably traced
// into the standalone bundle. Copy their complete dependency closure from
// the installed root tree without overwriting packages already traced by
// Next.js. Include transformers here so its ONNX runtime closure is also
// guaranteed in Docker/standalone builds.
colocateLlmlinguaOptionals({
rootDir: projectRoot,
targetNodeModulesDir: path.join(resolvedOutDir, "node_modules"),
seeds: [...SEED_PACKAGES, "@huggingface/transformers"],
log: (message) => console.log(`[assembleStandalone] ${message.trim()}`),
});
}
// 7. Optionally dereference Turbopack hashed-module symlinks so the bundle is

View File

@@ -96,7 +96,16 @@ function runNextBuild() {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
const buildEnv = resolveNextBuildEnv(process.env);
ensureWindowsBuildProfileDirs(buildEnv);
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
const nextArgs = process.versions.bun
? [
"--preload",
path.join(projectRoot, "open-sse", "utils", "setupPolyfill.ts"),
nextBin,
"build",
resolveNextBuildBundlerFlag(),
]
: [nextBin, "build", resolveNextBuildBundlerFlag()];
const child = spawn(process.execPath, nextArgs, {
cwd: projectRoot,
stdio: "inherit",
env: buildEnv,
@@ -122,12 +131,12 @@ function runNextBuild() {
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
// the explicit escape hatch (=0) for bundler-compat regressions.
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
// Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
// use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
return "--webpack";
}
return "--turbopack";
}
/**
@@ -327,7 +336,12 @@ export async function main() {
distDir,
outDir: standaloneDir,
projectRoot,
// Match the hardened packaging path used by Electron builds:
// Turbopack can emit hashed external-package references and
// standalone symlinks that break after the bundle is moved/copied.
patchTurbopackChunks: true,
copyNatives: true,
materializeSymlinks: true,
});
const { spawnSync } = await import("node:child_process");
const basePathWrite = spawnSync(

View File

@@ -0,0 +1,120 @@
/**
* Build provenance — is this artifact actually built from the release line? (#10427)
*
* `scripts/build/write-build-sha.mjs` stamps `dist/BUILD_SHA` into every packaged build,
* but nothing ever verified that the SHA belongs to the release branch. A tarball built
* from a feature branch installs and serves traffic indistinguishably from a release one.
*
* That gap took down the internal gateway on 2026-08-14: the installed package carried
* `BUILD_SHA = 178febc50f`, a commit on `fix/9603-qwen-token-plan-quota` that predated
* #10373, so it shipped the nominal `instanceof Response` guard from #10256 and answered
* every request with `502 … Executor result must contain a Response`.
*
* Kept as pure functions (the ancestry probe is injected) so the policy is unit-testable
* without a git fixture, and so the caller decides how strict to be per environment.
*/
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
export type BuildProvenanceReason =
| "on-release-line"
| "off-release-line"
| "canary-override"
| "missing-sha";
export type BuildProvenanceResult = {
ok: boolean;
reason: BuildProvenanceReason;
message: string;
};
export type BuildProvenanceInput = {
/** Contents of `dist/BUILD_SHA` (empty when the sentinel is absent). */
buildSha: string;
/** Whether `buildSha` is an ancestor of the release ref. Injected so this stays pure. */
isAncestorOfRelease: (sha: string) => boolean;
/** Deliberate canary build — allowed, but always reported. */
allowOverride: boolean;
};
/**
* Read `dist/BUILD_SHA` from a package root. Returns "" when absent — an unstamped build
* is a policy decision for the caller, not an exception here.
*/
export function readBuildSha(packageRoot: string): string {
try {
return fs.readFileSync(path.join(packageRoot, "dist", "BUILD_SHA"), "utf8").trim();
} catch {
return "";
}
}
/**
* Classify a build SHA against the release line.
*
* A missing SHA fails even with the override on: an artifact that cannot be identified
* cannot be vouched for, and "canary" is a statement about a KNOWN commit.
*/
export function resolveBuildProvenance(input: BuildProvenanceInput): BuildProvenanceResult {
const { buildSha, isAncestorOfRelease, allowOverride } = input;
if (!buildSha) {
return {
ok: false,
reason: "missing-sha",
message:
"dist/BUILD_SHA is missing — the artifact cannot be traced to a commit. " +
"Build with `npm run build:release` (or run scripts/build/write-build-sha.mjs).",
};
}
if (isAncestorOfRelease(buildSha)) {
return {
ok: true,
reason: "on-release-line",
message: `BUILD_SHA ${buildSha} is on the release line.`,
};
}
if (allowOverride) {
return {
ok: true,
reason: "canary-override",
message:
`BUILD_SHA ${buildSha} is NOT on the release line — allowed as a canary build ` +
"because OMNIROUTE_ALLOW_CANARY_BUILD=1 was set.",
};
}
return {
ok: false,
reason: "off-release-line",
message:
`BUILD_SHA ${buildSha} is not an ancestor of the release branch. Shipping it means ` +
"serving code that never passed the release gates (see #10427). Rebuild from the " +
"release tip, or set OMNIROUTE_ALLOW_CANARY_BUILD=1 to record this as a deliberate canary.",
};
}
/**
* Default ancestry probe: `git merge-base --is-ancestor <sha> <releaseRef>`.
*
* Any git failure (shallow clone, unknown ref, SHA not fetched) resolves to `false` —
* "cannot prove it is on the release line" is the safe answer for a gate whose whole
* purpose is to refuse unverifiable artifacts.
*/
export function makeGitAncestryProbe(releaseRef: string, cwd: string): (sha: string) => boolean {
return (sha: string) => {
try {
execFileSync("git", ["merge-base", "--is-ancestor", sha, releaseRef], {
cwd,
stdio: "ignore",
});
return true;
} catch {
return false;
}
};
}

View File

@@ -0,0 +1,162 @@
/**
* OmniRoute — cross-platform spawning of locally installed build tools.
*
* WHY: `node_modules/.bin/<tool>` (no extension) is a POSIX shell script. On
* Windows the executable shim is `<tool>.cmd`, so `execFileSync(join(ROOT,
* "node_modules", ".bin", "esbuild"), …)` dies with
*
* Error: spawnSync C:\…\node_modules\.bin\esbuild ENOENT
*
* and — because the `postbuild` hook runs after a SUCCESSFUL `next build` — the
* operator sees "✓ Compiled successfully" immediately followed by a failed
* `npm run build`, with a complete `.build/next/standalone` tree on disk.
*
* Switching to `<tool>.cmd` alone is not enough: since the CVE-2024-27980
* hardening, Node >= 20 refuses to spawn a `.cmd`/`.bat` without a shell
* (EINVAL), and `shell: true` in turn disables argument escaping (DEP0190).
*
* So the preferred path avoids the shim entirely: read the tool's own `bin`
* entry from its package.json and run THAT with this Node binary — no shim, no
* shell, nothing to escape, identical behaviour on every platform. The `.bin`
* shim stays only as a last resort for a tool that is not resolvable inside the
* local dependency tree.
*
* These helpers were private to `scripts/build/prepublish.ts`, where the same
* Windows failure was already fixed; they live here so plain-`node` build
* scripts (`postbuild` → colocate-standalone.mjs) can share one implementation
* instead of re-learning the same lesson. `planBuildToolSpawn()` takes the
* platform as a parameter — like `resolveNextBuildEnv()` in
* build-next-isolated.mjs — so the Windows behaviour is unit-testable from CI's
* Linux runners.
*/
import { execFileSync } from "node:child_process";
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
/**
* Absolute path of a tool's own `bin` entry inside the local dependency tree,
* or `null` when the package (or the entry it advertises) is not there.
*
* @param {string} packageName Package that ships the tool, e.g. `"esbuild"`.
* @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`.
* @param {string} [root] Directory holding `node_modules` (defaults to repo root).
* @returns {string | null}
*/
export function resolveLocalBinEntry(packageName, binName, root = ROOT) {
try {
const packageJsonPath = join(root, "node_modules", packageName, "package.json");
if (!existsSync(packageJsonPath)) return null;
const meta = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const relative = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName];
if (!relative) return null;
const absolute = join(root, "node_modules", packageName, relative);
return existsSync(absolute) ? absolute : null;
} catch {
return null;
}
}
/**
* Does this file start with an executable image's magic bytes?
*
* esbuild >= 0.25 ships `bin/esbuild` as the NATIVE platform executable on
* Linux/macOS (ELF / Mach-O) instead of a JS shim — handing that to
* `process.execPath` makes Node parse machine code as JavaScript and die with
* "SyntaxError: Invalid or unexpected token". Native entries must be executed
* directly; JS entries go through this Node binary.
*
* @param {string} entryPath
* @returns {boolean}
*/
export function isNativeExecutable(entryPath) {
try {
const fd = openSync(entryPath, "r");
const head = Buffer.alloc(4);
readSync(fd, head, 0, 4, 0);
closeSync(fd);
return (
(head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF
head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64
head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk)
(head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ)
);
} catch {
return false;
}
}
/**
* `cmd.exe` receives one flat command line, and Node does NOT escape arguments
* when `shell` is set, so anything holding whitespace has to be quoted here.
* Build arguments carry absolute paths, and `C:\Users\First Last\…` is an
* ordinary Windows home directory.
*
* @param {string} value
* @returns {string}
*/
function quoteForShell(value) {
if (!/\s/.test(value) || value.startsWith('"')) return value;
return `"${value}"`;
}
/**
* Decide HOW to spawn a build tool. Pure: no filesystem access, no `process`
* inspection beyond `execPath`, platform injected — so a Linux test can assert
* the Windows plan.
*
* @param {object} input
* @param {string} input.binName Tool name as it appears in `node_modules/.bin`.
* @param {readonly string[]} input.args Arguments for the tool.
* @param {string | null} [input.entryPath] Result of {@link resolveLocalBinEntry}.
* @param {boolean} [input.entryIsNative] Result of {@link isNativeExecutable}.
* @param {string} [input.root] Directory holding `node_modules`.
* @param {string} [input.platform] `process.platform` value to plan for.
* @returns {{ file: string, args: string[], shell: boolean }} `file`/`args` are
* already shell-quoted when `shell` is true, and must be passed together.
*/
export function planBuildToolSpawn({
binName,
args,
entryPath = null,
entryIsNative = false,
root = ROOT,
platform = process.platform,
}) {
// Preferred: the tool's own entry point, spawned with no shim and no shell.
if (entryPath) {
return entryIsNative
? { file: entryPath, args: [...args], shell: false }
: { file: process.execPath, args: [entryPath, ...args], shell: false };
}
// Last resort: the `node_modules/.bin` shim. On Windows that means the `.cmd`
// variant, which Node only spawns through a shell (see the module header).
const isWindows = platform === "win32";
const shim = join(root, "node_modules", ".bin", isWindows ? `${binName}.cmd` : binName);
return isWindows
? { file: quoteForShell(shim), args: args.map(quoteForShell), shell: true }
: { file: shim, args: [...args], shell: false };
}
/**
* Run a locally installed build tool, synchronously, on any platform.
*
* @param {string} packageName Package that ships the tool, e.g. `"esbuild"`.
* @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`.
* @param {readonly string[]} args Arguments for the tool.
* @param {import("node:child_process").ExecFileSyncOptions} [options] Passed to `execFileSync`.
* @returns {void}
*/
export function runBuildTool(packageName, binName, args, options = {}) {
const entryPath = resolveLocalBinEntry(packageName, binName);
const plan = planBuildToolSpawn({
binName,
args,
entryPath,
entryIsNative: entryPath ? isNativeExecutable(entryPath) : false,
});
execFileSync(plan.file, plan.args, plan.shell ? { ...options, shell: true } : options);
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* OmniRoute — Co-locate runtime workers into the raw Next standalone build.
*
* WHY: `npm run build` produces `.build/next/standalone/` and THIS machine's PM2
* deployment runs `server.js` from that directory directly (not the assembled
* `dist/` bundle). The standalone trace cannot see worker_threads entrypoints
* resolved at runtime, including the required call-log artifact worker and the
* optional LLMLingua-2 worker (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`,
* dynamically spawned via worker_threads — untraceable by webpack). It also omits
* LLMLingua's optional SLM deps (`@atjsh/llmlingua-2`, `js-tiktoken`) — they are
* optionalDependencies and are only installed at the ROOT `node_modules`.
*
* The call-log worker is required, so a bundle failure must fail the build.
* LLMLingua remains fail-soft when its optional dependencies are absent.
*
* Run manually after a build, or automatically via the `postbuild` npm hook.
*/
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { runBuildTool } from "./buildToolRunner.mjs";
import { computeDependencyClosure } from "./colocateOptionals.mjs";
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
// STANDALONE defaults to the real build output; OMNIROUTE_STANDALONE_DIR overrides
// it so tests can drive the co-location logic against a synthetic tree without a
// full `next build`. Mirrors the OMNIROUTE_* override seams in the sibling build
// scripts (write-build-sha.mjs, write-build-base-path.mjs, optionalPackStaging.mjs).
const STANDALONE = process.env.OMNIROUTE_STANDALONE_DIR
? process.env.OMNIROUTE_STANDALONE_DIR
: join(ROOT, ".build", "next", "standalone");
const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js");
const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts");
const WORKER_REL = join(
"open-sse",
"services",
"compression",
"engines",
"llmlingua",
"onnxWorker.js"
);
/**
* Give each esbuild'd ESM worker its OWN `"type":"module"` scope.
*
* The worker bundles are emitted with `--format=esm` under `.js` names, so Node
* needs a nearest-ancestor package.json declaring `"type":"module"` to load them
* as ESM. It is tempting to set that on the standalone ROOT package.json, but the
* standalone entrypoint `server.js` is CommonJS (`require()`, `__dirname`); a root
* `"type":"module"` makes Node parse server.js as ESM and it crashes at startup
* with `ReferenceError: require is not defined in ES module scope`.
* assembleStandalone.mjs::patchStandalonePackageJson strips `type` for exactly
* this reason — re-adding it on the root here reintroduced that crash.
*
* Node resolves module type from the NEAREST package.json, so a scoped
* `{"type":"module"}` beside each worker makes the worker ESM while the root stays
* CommonJS for server.js. Both coexist with no format change and no root edit.
*
* @param {string[]} workerDirs Absolute directories that hold an ESM worker bundle.
* @returns {string[]} The package.json paths that were written (existing ones are left intact).
*/
export function writeEsmWorkerScopes(workerDirs) {
const written = [];
for (const dir of workerDirs) {
const scopedPkgPath = join(dir, "package.json");
if (existsSync(scopedPkgPath)) continue; // never clobber a traced package.json
try {
writeFileSync(scopedPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf8");
written.push(scopedPkgPath);
console.log(`[colocate-standalone] ✅ ESM scope written: ${scopedPkgPath}`);
} catch (err) {
console.warn(`[colocate-standalone] ⚠️ could not write ESM scope for ${dir}:`, err.message);
}
}
return written;
}
function main() {
const hasOptionals = existsSync(
join(ROOT, "node_modules", "@atjsh", "llmlingua-2", "package.json")
);
if (!existsSync(STANDALONE)) {
console.log("[colocate-standalone] .build/next/standalone not found — nothing to do.");
return;
}
const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL);
mkdirSync(dirname(callLogWorkerDest), { recursive: true });
// Never spawn `node_modules/.bin/esbuild` directly: that extensionless path is
// a POSIX shell script and does not exist on Windows (ENOENT), which failed
// `npm run build` right after a successful `next build`. See buildToolRunner.mjs.
runBuildTool(
"esbuild",
"esbuild",
[
CALL_LOG_WORKER_SRC,
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
`--outfile=${callLogWorkerDest}`,
],
{ stdio: "inherit" }
);
console.log("[colocate-standalone] ✅ call-log artifact worker bundled");
// The call-log worker is always present; scope it to ESM immediately. The
// optional LLMLingua worker dir is added below only when its deps are installed.
const workerDirs = [dirname(callLogWorkerDest)];
if (!hasOptionals) {
console.log(
"[colocate-standalone] optional SLM deps absent at root node_modules — LLMLingua stays fail-open (slim install)."
);
writeEsmWorkerScopes(workerDirs);
return;
}
// 1) Bundle the worker the resolver expects: <standalone>/open-sse/.../onnxWorker.js
const workerDest = join(STANDALONE, WORKER_REL);
if (!existsSync(workerDest)) {
mkdirSync(dirname(workerDest), { recursive: true });
try {
runBuildTool(
"esbuild",
"esbuild",
[
join(
ROOT,
"open-sse",
"services",
"compression",
"engines",
"llmlingua",
"onnxWorker.ts"
),
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
`--outfile=${workerDest}`,
],
{ stdio: "inherit" }
);
console.log("[colocate-standalone] ✅ LLMLingua worker bundled into standalone tree");
} catch (err) {
console.warn("[colocate-standalone] ⚠️ worker bundle error:", err.message);
}
} else {
console.log("[colocate-standalone] worker already present (skipping bundle)");
}
workerDirs.push(dirname(workerDest));
// 2) Co-locate the optional-dep closure (NO-CLOBBER, same semantics as colocateOptionals.mjs)
const srcNm = join(ROOT, "node_modules");
const dstNm = join(STANDALONE, "node_modules");
const closure = computeDependencyClosure(srcNm);
let copied = 0;
for (const pkg of closure) {
const src = join(srcNm, pkg);
const dst = join(dstNm, pkg);
if (!existsSync(src)) continue;
if (existsSync(dst)) continue; // no-clobber: keep traced instances (e.g. pinned @huggingface/transformers)
mkdirSync(dirname(dst), { recursive: true });
cpSync(src, dst, { recursive: true });
copied++;
}
console.log(
`[colocate-standalone] ✅ optional-dep closure: ${closure.length} packages (copied ${copied})`
);
// 3) Give each esbuild'd ESM worker its own "type":"module" scope (see helper doc).
writeEsmWorkerScopes(workerDirs);
}
// Run as a script (npm `postbuild` hook), but stay importable for unit tests.
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entryScript === import.meta.url) {
main();
}

View File

@@ -4,31 +4,32 @@
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* `@huggingface/transformers` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* (4.2.0, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* `dist/node_modules/@huggingface/transformers` (4.2.0) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
* tier silently fails-open (no compression). (Before `@atjsh/llmlingua-2@2.0.5` a root
* transformers on the 4.x line also made llmlingua-2 throw on a tokenizer-API change
* — `decoder.decode` is undefined; 2.0.5+ supports both v3 and v4.)
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 4.2.0 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* SAME `dist/node_modules` — a single 4.2.0 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
@@ -46,14 +47,15 @@
* fail-open, so this never throws into the install.
*/
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, sep } from "node:path";
/**
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
@@ -97,39 +99,104 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES)
}
/**
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
* absent, and idempotent once co-located. Never throws.
* A package in the target tree counts as PRESENT only when its entrypoint
* resolves from inside that tree — the same contract the Dockerfile's
* post-build guard enforces. Next's file tracing can materialize a package
* PARTIALLY (the package.json lands, the files its `main` points at do not),
* and a directory-level `existsSync` check then skips the package forever
* while the runtime dies with "Cannot find module <pkg>/dist/index.js".
*
* @param {{ rootDir: string, log?: (message: string) => void }} opts
* @param {string} targetNodeModulesDir
* @param {string} name
* @returns {boolean}
*/
function isPackageIntact(targetNodeModulesDir, name) {
if (!existsSync(join(targetNodeModulesDir, name))) return false;
try {
const probe = createRequire(
join(targetNodeModulesDir, "__colocate_probe__.js")
);
const resolved = probe.resolve(name);
// A resolution that walked past the target into an ancestor tree does not
// prove the target copy is usable.
const realTarget = realpathSync(targetNodeModulesDir);
const realResolved = realpathSync(resolved);
return realResolved.startsWith(realTarget + sep);
} catch {
return false;
}
}
/**
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
* into a standalone bundle's `node_modules`.
*
* The default destination remains `<rootDir>/dist/node_modules` for the npm
* postinstall path. Standalone builders, including Docker, may provide
* `targetNodeModulesDir`.
*
* Packages already present in the destination are never overwritten. This
* preserves the standalone bundle's pinned dependency instances while filling
* dynamically imported packages that Next.js did not trace.
*
* @param {{
* rootDir: string,
* targetNodeModulesDir?: string,
* seeds?: string[],
* log?: (message: string) => void
* }} opts
* @returns {{ skipped: true, reason: string }
* | { skipped: false, copied: number, closure: number }}
*/
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
export function colocateLlmlinguaOptionals({
rootDir,
targetNodeModulesDir,
seeds = SEED_PACKAGES,
log = () => {},
}) {
const rootNm = join(rootDir, "node_modules");
const distNm = join(rootDir, "dist", "node_modules");
const targetNm = targetNodeModulesDir ?? join(rootDir, "dist", "node_modules");
if (!existsSync(distNm)) {
return { skipped: true, reason: "no standalone dist/node_modules" };
if (!existsSync(targetNm)) {
return {
skipped: true,
reason: targetNodeModulesDir ? "no target node_modules" : "no standalone dist/node_modules",
};
}
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
// Only run when every requested closure root was installed.
if (!seeds.every((seed) => existsSync(join(rootNm, seed)))) {
return { skipped: true, reason: "SLM optionals not installed at root" };
}
// Idempotent: the entry package is already co-located → nothing to do.
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
const closure = computeDependencyClosure(rootNm, seeds);
// Check the complete closure rather than only the entry package, and judge
// presence by entrypoint integrity — a partially traced directory (see
// isPackageIntact) must still receive its missing files.
if (
closure.length > 0 &&
closure.every((name) => isPackageIntact(targetNm, name))
) {
return { skipped: true, reason: "already co-located" };
}
const closure = computeDependencyClosure(rootNm);
let copied = 0;
for (const name of closure) {
const dest = join(distNm, name);
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
const dest = join(targetNm, name);
if (isPackageIntact(targetNm, name)) continue;
try {
mkdirSync(dirname(dest), { recursive: true });
cpSync(join(rootNm, name), dest, { recursive: true });
// force:false merges into a partially traced directory: files the trace
// already materialized are kept, missing ones (the package payload) are
// filled in from the root tree.
cpSync(join(rootNm, name), dest, {
recursive: true,
force: false,
errorOnExist: false,
});
copied++;
} catch (err) {
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
@@ -137,7 +204,9 @@ export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
}
if (copied > 0) {
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
log(
` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into standalone node_modules.\n`
);
}
return { skipped: false, copied, closure: closure.length };

View File

@@ -0,0 +1,142 @@
/**
* Opt-in iframe embedding for OmniRoute's HTML pages (#10273).
*
* OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, which
* is the right default for a proxy that holds provider credentials. The OmniCopilot VS Code
* extension, however, renders the dashboard inside the built-in Simple Browser — an iframe
* whose ancestor is a `vscode-webview:` document — so the strict default paints a blank tab.
*
* Setting `DASHBOARD_ALLOW_EMBED=vscode` at build time swaps the page surface to
* `frame-ancestors 'self' vscode-webview:` and drops `X-Frame-Options` for those pages.
* XFO has no syntax for a custom scheme, and keeping `DENY` alongside a permissive
* `frame-ancestors` would still block the frame in engines that honour XFO first — dropping
* it is required, not cosmetic. (Modern engines ignore XFO entirely once `frame-ancestors`
* is present, so nothing is lost where CSP is supported.)
*
* The API surface is deliberately left out: `/api/*`, `/v1*`, `/a2a`, `/healthz` and every
* root-level rewrite alias keep the strict headers even in embed mode. Those are the
* Hard-Rule-15/17 process-spawning and proxy surfaces and never need framing.
*
* Build-time by design: Next.js resolves `headers()` when the config loads, matching the
* existing env-driven knobs in `next.config.mjs` (`OMNIROUTE_BASE_PATH`,
* `OMNIROUTE_BUILD_PROFILE`, …). Changing the value requires a rebuild.
*/
export const DASHBOARD_EMBED_ENV = "DASHBOARD_ALLOW_EMBED";
/** Ancestor allow-list per supported embed mode. Adding a mode here is the only extension point. */
export const EMBED_FRAME_ANCESTORS = Object.freeze({
// `vscode-webview:` is the scheme VS Code assigns to webview/Simple Browser documents.
// `'self'` keeps OmniRoute's own same-origin frames (e.g. the G-10 9Router embed) working.
vscode: "'self' vscode-webview:",
});
/** The strict `frame-ancestors` token the CSP carries by default. */
export const STRICT_FRAME_ANCESTORS = "frame-ancestors 'none'";
/**
* App-router surfaces that are not HTML pages and have no `rewrites()` alias to derive them
* from. Everything else in the exclusion list comes from the rewrite table, so a future API
* alias is excluded automatically instead of silently becoming framable.
*/
export const STATIC_NON_PAGE_PREFIXES = Object.freeze(["api", "a2a", "healthz"]);
/**
* Resolve the opt-in embed mode from the environment.
* Unknown / truthy-looking values (`1`, `true`, `on`) intentionally do NOT enable embedding:
* the operator must name the ancestor family they are opening up.
*
* @param {Record<string, string | undefined>} env
* @returns {"vscode" | null}
*/
export function resolveDashboardEmbedMode(env = process.env) {
const raw = env?.[DASHBOARD_EMBED_ENV];
if (typeof raw !== "string") return null;
const normalized = raw.trim().toLowerCase();
return Object.hasOwn(EMBED_FRAME_ANCESTORS, normalized) ? normalized : null;
}
/**
* The first path segment of every route that must stay unframable, derived from the
* `rewrites()` table plus the static app-router API surfaces.
*
* @param {{ source: string }[]} rewriteRules
* @returns {string[]} sorted, de-duplicated prefixes
*/
export function nonPageRoutePrefixes(rewriteRules = []) {
const prefixes = new Set(STATIC_NON_PAGE_PREFIXES);
for (const { source } of rewriteRules) {
const first = source.replace(/^\//, "").split("/")[0];
// Skip parameterised first segments (`/:path*`) — they would exclude the whole site.
if (first && !first.startsWith(":")) prefixes.add(first);
}
return [...prefixes].sort();
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Two complementary Next.js `source` patterns built from the same prefix list, so the union
* covers every pathname exactly once — no gap (a page with no security headers) and no
* overlap (an order-dependent merge).
*
* @param {string[]} prefixes
* @returns {{ nonPageSource: string, pageSource: string }}
*/
export function complementarySources(prefixes) {
const alternation = prefixes.map(escapeRegExp).join("|");
const boundary = `(?:${alternation})(?:/|$)`;
return {
nonPageSource: `/((?=${boundary}).*)`,
pageSource: `/((?!${boundary}).*)`,
};
}
/**
* Swap only the `frame-ancestors` token of an existing CSP, leaving every other directive
* byte-identical.
*
* @param {string} contentSecurityPolicy
* @param {"vscode"} mode
*/
export function relaxFrameAncestors(contentSecurityPolicy, mode) {
return contentSecurityPolicy.replace(
STRICT_FRAME_ANCESTORS,
`frame-ancestors ${EMBED_FRAME_ANCESTORS[mode]}`
);
}
/**
* Build the `headers()` rules carrying OmniRoute's baseline security headers.
*
* With embedding off this returns the single catch-all rule the config has always had, so a
* default build is unchanged. With embedding on it returns two complementary rules: the API
* surface keeps the strict headers, the page surface gets the relaxed CSP and no XFO.
*
* @param {{
* mode: "vscode" | null,
* securityHeaders: { key: string, value: string }[],
* prefixes?: string[],
* }} options
* @returns {{ source: string, headers: { key: string, value: string }[] }[]}
*/
export function buildSecurityHeaderRules({ mode, securityHeaders, prefixes = [] }) {
if (!mode) return [{ source: "/:path*", headers: securityHeaders }];
const { nonPageSource, pageSource } = complementarySources(prefixes);
const pageHeaders = securityHeaders
// X-Frame-Options cannot express `vscode-webview:` and would veto the relaxed CSP.
.filter((header) => header.key !== "X-Frame-Options")
.map((header) =>
header.key === "Content-Security-Policy"
? { key: header.key, value: relaxFrameAncestors(header.value, mode) }
: header
);
return [
{ source: nonPageSource, headers: securityHeaders },
{ source: pageSource, headers: pageHeaders },
];
}

View File

@@ -1,17 +1,73 @@
/**
* Spawn plan for the better-sqlite3 Electron-ABI rebuild (pure — import-safe for tests).
* better-sqlite3 Node-API prebuild planning (pure — import-safe for tests).
*
* On Windows, `npx.cmd` MUST be spawned through a shell: since Node's
* CVE-2024-27980 hardening, spawning `.cmd`/`.bat` shims without `shell: true`
* fails outright (spawnSync returns `status: null`), which broke the v3.8.47
* tag build ("better-sqlite3 rebuild against electron 43.1.0 failed (exit null)").
* The args are a fixed literal list — no untrusted input reaches the shell.
* Since better-sqlite3 v13 the packaged app no longer compiles the addon from
* source against the Electron headers: v13 ships Node-API (NAPI_VERSION=10)
* prebuilds for every platform we package, and Node-API addons are
* ABI-independent, so the same prebuild runs under plain Node and under the
* packaged app's ELECTRON_RUN_AS_NODE server (verified against electron 43 /
* NODE_MODULE_VERSION 148 — issue #10321 Stage 6). The historical
* `npx node-gyp rebuild` spawn plan existed because better-sqlite3@12 only
* shipped prebuilds up to electron-v146; v13 makes it obsolete.
*
* This module mirrors better-sqlite3's own `lib/binding.js` selection logic so
* the build fails fast when the prebuild the runtime loader would pick is
* missing, instead of shipping an app that falls back to sql.js and OOMs on a
* user machine.
*/
export function buildRebuildSpawnPlan(platform) {
const win = platform === "win32";
return {
command: win ? "npx.cmd" : "npx",
args: ["--yes", "node-gyp", "rebuild"],
shell: win,
};
import { existsSync } from "node:fs";
import { join } from "node:path";
export const SQLITE_PREBUILD_PLATFORMS = ["darwin", "linux", "linuxmusl", "win32"];
export const SQLITE_PREBUILD_ARCHS = ["x64", "arm64"];
/**
* Resolve the prebuild file name better-sqlite3's loader would pick for the
* given platform/arch. Mirrors lib/binding.js: linux without a glibc runtime
* version resolves to the linuxmusl prebuild.
*
* @param {string} platform - process.platform ("linux", "darwin", "win32")
* @param {string} arch - process.arch ("x64", "arm64")
* @param {{ glibcVersionRuntime?: string | null }} [reportHeader] - parsed
* process.report.getReport().header (injectable for tests)
*/
export function sqlitePrebuildFileName(platform, arch, reportHeader) {
const isMusl = platform === "linux" && !reportHeader?.glibcVersionRuntime;
const target = `${isMusl ? "linuxmusl" : platform}-${arch}`;
return `${target}.node`;
}
/**
* Whether a prebuild check applies for this platform/arch combination.
* Unsupported combos (e.g. freebsd-ia32) are skipped rather than failed: the
* runtime loader falls back to node-gyp build/ locations for those, which we
* do not package.
*/
export function isSqlitePrebuildSupported(platform, arch) {
return SQLITE_PREBUILD_PLATFORMS.includes(platform) && SQLITE_PREBUILD_ARCHS.includes(arch);
}
/**
* Assert that the runtime-selected prebuild exists in a staged module.
* Unsupported platform/arch combinations retain the historical fallback path.
*
* @returns {string | null} selected prebuild path, or null when unsupported
*/
export function assertSqlitePrebuildExists(moduleDir, platform, arch, reportHeader) {
if (!isSqlitePrebuildSupported(platform, arch)) return null;
const expected = join(
moduleDir,
"prebuilds",
sqlitePrebuildFileName(platform, arch, reportHeader)
);
if (!existsSync(expected)) {
throw new Error(
`[electron] better-sqlite3 prebuild missing for ${platform}-${arch} ` +
`(${expected}). The packaged app would fall back to sql.js and OOM. ` +
`Restore the prebuilds/ directory (npm cache / registry tarball) before packaging.`
);
}
return expected;
}

View File

@@ -0,0 +1,65 @@
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
export const ELECTRON_RUNTIME_DOC_PRUNE_RULES = Object.freeze({
localeRootFiles: Object.freeze(["CHANGELOG.md"]),
authoringDirectories: Object.freeze(["docs/research", "docs/superpowers"]),
});
function payloadSize(targetPath) {
const stat = lstatSync(targetPath);
if (!stat.isDirectory()) {
return { files: 1, bytes: stat.size };
}
return readdirSync(targetPath).reduce(
(total, entry) => {
const payload = payloadSize(join(targetPath, entry));
total.files += payload.files;
total.bytes += payload.bytes;
return total;
},
{ files: 0, bytes: 0 }
);
}
function removePayload(bundleRoot, relativePath, summary) {
const root = resolve(bundleRoot);
const targetPath = resolve(root, relativePath);
if (targetPath !== root && !targetPath.startsWith(`${root}${sep}`)) {
throw new Error(`[electron-docs] refusing to prune outside bundle root: ${relativePath}`);
}
if (!existsSync(targetPath)) return;
const payload = payloadSize(targetPath);
rmSync(targetPath, { recursive: true, force: true });
summary.removedFiles += payload.files;
summary.removedBytes += payload.bytes;
summary.removedPaths.push(relative(root, targetPath).split(sep).join("/"));
}
/**
* Remove docs that are useful while authoring OmniRoute but are never read by
* the packaged desktop runtime. Canonical docs remain untouched; bundleRoot is
* the disposable Electron staging directory.
*/
export function pruneElectronRuntimeDocs(bundleRoot) {
const summary = { removedFiles: 0, removedBytes: 0, removedPaths: [] };
const localesRoot = join(bundleRoot, "docs", "i18n");
if (existsSync(localesRoot)) {
for (const locale of readdirSync(localesRoot, { withFileTypes: true })) {
if (!locale.isDirectory()) continue;
for (const fileName of ELECTRON_RUNTIME_DOC_PRUNE_RULES.localeRootFiles) {
removePayload(bundleRoot, join("docs", "i18n", locale.name, fileName), summary);
}
}
}
for (const relativePath of ELECTRON_RUNTIME_DOC_PRUNE_RULES.authoringDirectories) {
removePayload(bundleRoot, relativePath, summary);
}
summary.removedPaths.sort();
return summary;
}

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* playwright-core Android/Termux platform patch (#7265).
*
* playwright-core's bundled coreBundle.js has three IIFEs that compute the
* browser-cache directory by checking `process.platform` for "linux", "darwin",
* or "win32". On Android (Termux), Node.js may report process.platform as
* "android", causing each IIFE to throw "Unsupported platform: android" at
* module load time — crashing the entire server before any browser is launched.
*
* This script patches the three platform checks to also accept "android",
* treating it identically to "linux" (same XDG_CACHE_HOME convention).
*
* The patch is applied to both root node_modules (for dev/build) and
* dist/node_modules (for the standalone bundle). It is idempotent — running
* multiple times is safe.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7265
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const PATCHED_MARKER = "/* omniroute-android-patch */";
/**
* Patch coreBundle.js to accept Android as a valid platform.
* Returns true if the file was modified, false if already patched or not found.
*/
function patchCoreBundle(filePath) {
if (!existsSync(filePath)) return false;
let content = readFileSync(filePath, "utf8");
// Already patched — skip
if (content.includes(PATCHED_MARKER)) return false;
// The three platform-check patterns in coreBundle.js:
// 1. defaultCacheDirectory IIFE (line ~28594)
// 2. defaultCacheDirectory2 IIFE (line ~51278)
// 3. daemon session dir computation (line ~68847)
//
// Original pattern: if (process.platform === "linux")
// Patched pattern: if (process.platform === "linux" || process.platform === "android")
//
// We use a regex that matches the exact pattern and only replaces the first
// occurrence in each of the three IIFEs. The marker comment is appended once
// to signal idempotency.
const original = /if \(process\.platform === "linux"\)/g;
const patched = `if (process.platform === "linux" || process.platform === "android") ${PATCHED_MARKER}`;
const count = (content.match(original) || []).length;
if (count === 0) {
// Either already patched or different version — check for our marker
return false;
}
content = content.replace(original, patched);
writeFileSync(filePath, content, "utf8");
return true;
}
export function fixPlaywrightAndroid({ rootDir, log = (m) => console.log(m) } = {}) {
const targets = [
join(rootDir, "node_modules", "playwright-core", "lib", "coreBundle.js"),
join(rootDir, "dist", "node_modules", "playwright-core", "lib", "coreBundle.js"),
];
let patched = 0;
for (const target of targets) {
if (patchCoreBundle(target)) {
patched++;
log(` ✅ Patched playwright-core for Android: ${target}`);
}
}
if (patched > 0) {
log(` ✅ playwright-core Android patch applied (${patched} file(s))\n`);
}
return patched;
}
// When run directly (not imported), execute the patch
if (process.argv[1] && process.argv[1].endsWith("fixPlaywrightAndroid.mjs")) {
const rootDir = process.argv[2] || process.cwd();
fixPlaywrightAndroid({ rootDir });
}

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* Platform hydration for the shared Next standalone web build (issue #10321,
* Stage 8).
*
* The standalone bundle is built ONCE on ubuntu and restored on every desktop
* matrix leg. Everything except install-machine-forked optional packages is
* platform-independent:
*
* - Bundled-for-all (verify only): koffi ships every triplet under
* `build/koffi/<os>_<arch>`, better-sqlite3 v13 ships Node-API prebuilds for
* 8 platforms, wreq-js ships `rust/wreq-js.<plat>-<arch>[-libc].node`, and
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
* ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg
* replaces them with the forks from its OWN `npm ci`d node_modules.
*/
import fs from "node:fs";
import path from "node:path";
/** Scope prefixes whose members are install-machine-forked. */
export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"];
/** Standalone packages that are not forked but must never be platform-forked. */
export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
/**
* onnxruntime-node does not publish a darwin-x64 binary for napi-v6 (only
* linux/win32 x64 + darwin arm64), so existence cannot be asserted there.
*/
export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]);
function platformTriple(platform, arch) {
// koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes.
return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` };
}
function rmrf(target) {
fs.rmSync(target, { recursive: true, force: true });
}
function copyDir(from, to) {
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: false, force: true });
}
function directMemberNames(nodeModulesDir, scope) {
const scopeDir = path.join(nodeModulesDir, ...scope.split("/").slice(0, -1));
const prefix = scope.split("/").pop();
try {
return fs
.readdirSync(scopeDir)
.filter((name) => name.startsWith(prefix))
.map((name) => `${scope.slice(0, scope.lastIndexOf("/"))}/${name}`);
} catch {
return [];
}
}
/**
* Replace install-machine-forked packages inside the restored standalone tree
* with the forks resolved by THIS machine's node_modules.
*
* @param {{standaloneNodeModules: string, sourceNodeModules: string}} opts
* @returns {{replaced: string[], removed: string[], copied: string[]}}
*/
export function hydratePlatformNatives({ standaloneNodeModules, sourceNodeModules }) {
const replaced = [];
const removed = [];
const copied = [];
const forkedNames = new Set();
for (const scope of HYDRATED_SCOPES) {
for (const name of directMemberNames(sourceNodeModules, scope)) forkedNames.add(name);
for (const name of directMemberNames(standaloneNodeModules, scope)) forkedNames.add(name);
}
for (const pkg of HYDRATED_ROOT_PACKAGES) {
if (fs.existsSync(path.join(sourceNodeModules, pkg))) forkedNames.add(pkg);
if (fs.existsSync(path.join(standaloneNodeModules, pkg))) forkedNames.add(pkg);
}
for (const name of forkedNames) {
const standalonePath = path.join(standaloneNodeModules, ...name.split("/"));
const sourcePath = path.join(sourceNodeModules, ...name.split("/"));
const hadIt = fs.existsSync(standalonePath);
const hasIt = fs.existsSync(sourcePath);
if (hadIt) rmrf(standalonePath);
if (!hasIt) {
if (hadIt) removed.push(name);
continue; // e.g. fsevents on non-darwin legs: simply absent everywhere.
}
copyDir(sourcePath, standalonePath);
copied.push(name);
if (hadIt) replaced.push(name);
}
return { replaced, removed, copied };
}
/**
* Assert that every bundled native dependency can service `platform`/`arch`.
*
* @returns {{ok: true} | {ok: false, errors: string[]}}
*/
export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
const errors = [];
const triple = platformTriple(platform, arch);
const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi);
if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`);
const sqlitePrebuild = path.join(
nodeModulesDir,
"better-sqlite3",
"prebuilds",
`${triple.dash}.node`
);
if (!fs.existsSync(sqlitePrebuild))
errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`);
const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust");
const wreqNames = fs.existsSync(wreqDir)
? fs
.readdirSync(wreqDir)
.filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node"))
: [];
if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`);
const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`);
if (!exempt) {
const onnxDir = path.join(nodeModulesDir, "onnxruntime-node", "bin", "napi-v6", platform, arch);
if (!fs.existsSync(onnxDir))
errors.push(`onnxruntime-node: missing ${platform}/${arch} binary`);
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

View File

@@ -0,0 +1,132 @@
/**
* Shared MCP publish-path helpers (#3578 / #3821).
*
* Unit tests use the static `files` allowlist walker (no subprocess).
* The pack-artifact gate uses the same helpers against a real
* `npm pack --dry-run --ignore-scripts` file list so concurrent unit
* suites never shell out to `npm pack`.
*/
import fs from "node:fs";
import path from "node:path";
import { normalizeArtifactPath } from "./pack-artifact-policy.ts";
/** Co-located test / spec paths that must never ship in the npm tarball. */
export const PACK_ARTIFACT_TEST_FILE_RE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Negations that must stay in package.json `files` (static unit guard). */
export const REQUIRED_PACKAGE_FILES_TEST_NEGATIONS: readonly string[] = [
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.js",
"!**/*.test.mjs",
"!**/*.spec.ts",
"!**/*.spec.tsx",
];
/** Spot-check file from the original #3578 bug report. */
export const MCP_CLOSURE_SPOT_CHECK_PATH = "src/lib/combos/steps.ts";
function resolveImport(root: string, fromFile: string, spec: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = path.join("src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse/"))
base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length));
else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index");
else if (spec.startsWith("./") || spec.startsWith("../"))
base = path.join(path.dirname(fromFile), spec);
else return null; // bare package — not our source
base = base.replace(/\.(ts|tsx|js|mjs)$/, "");
const cands = [
base + ".ts",
base + ".tsx",
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
base + ".js",
base + ".mjs",
];
for (const c of cands) if (fs.existsSync(path.join(root, c))) return c;
return null;
}
/**
* Transitive import closure of the MCP server entrypoints under `src/` + `open-sse/`.
*/
export function computeMcpClosure(root: string = process.cwd()): string[] {
const roots: string[] = [];
for (const f of fs.readdirSync(path.join(root, "open-sse/mcp-server"))) {
if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f);
}
for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) {
const abs = path.join(root, d);
if (fs.existsSync(abs))
for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f);
}
const seen = new Set<string>();
const stack = [...roots];
const importRe =
/(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
while (stack.length) {
const f = stack.pop() as string;
if (seen.has(f)) continue;
seen.add(f);
let src: string;
try {
src = fs.readFileSync(path.join(root, f), "utf8");
} catch {
continue;
}
let m: RegExpExecArray | null;
while ((m = importRe.exec(src))) {
const spec = m[1] || m[2];
if (!spec) continue;
const r = resolveImport(root, f, spec);
if (r && !seen.has(r)) stack.push(r);
}
}
return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/"));
}
/** Whether `file` is covered by a package.json `files` allowlist entry. */
export function isCoveredByFiles(file: string, filesEntries: string[]): boolean {
for (const entry of filesEntries) {
if (entry.startsWith("!")) continue; // negations are not positive coverage
if (entry.endsWith("/")) {
if (file === entry.slice(0, -1) || file.startsWith(entry)) return true;
} else if (file === entry || file.startsWith(entry + "/")) {
return true;
}
}
return false;
}
/** Packed paths that look like test / spec files (over-inclusion). */
export function findLeakedTestArtifactPaths(filePaths: string[]): string[] {
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => PACK_ARTIFACT_TEST_FILE_RE.test(filePath))
.sort();
}
/** MCP closure members missing from a packed (or candidate) path set. */
export function findMissingMcpClosurePaths(
packedPaths: string[],
closurePaths: string[] = computeMcpClosure()
): string[] {
const packed = new Set(packedPaths.map(normalizeArtifactPath).filter(Boolean));
return closurePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => !packed.has(filePath))
.sort();
}
/** Required `files` negation entries that are absent from package.json. */
export function findMissingPackageFilesTestNegations(filesEntries: string[]): string[] {
const present = new Set(filesEntries);
return REQUIRED_PACKAGE_FILES_TEST_NEGATIONS.filter((entry) => !present.has(entry));
}

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* OmniRoute — Stage 7 build-time optional-pack staging (issue #10321).
*
* Runs ONLY against the Electron staging tree (`.build/electron-standalone`),
* after `assembleStandalone()` and the native-module steps. For each optional
* pack in OPTIONAL_PACKS it:
*
* 1. checksums every member from the staged `node_modules` closure and emits
* `optional-packs.index.json` at the bundle root (one source of truth for
* the CLI installer and `verify`),
* 2. MOVES the member trees out of the staging bundle into
* `.build/optional-packs/<name>/node_modules/…` (same volume → cheap rename),
* 3. emits `optional-pack-<name>.tar.gz` next to them (bsdtar; disable with
* `OMNIROUTE_OPTIONAL_PACK_TAR=0`) for the desktop release workflow to
* upload as versioned assets.
*
* The shared Next standalone bundle (Docker / non-Electron deploys) is never
* touched — only the Electron staging copy, mirroring the Stage 5 doc pruner's
* boundary. Fail-open: members missing from staging are skipped with a warning
* (a future bundle graph change must not break packaging), but the index only
* records packs whose members were actually staged.
*/
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
OPTIONAL_PACKS,
PACK_INDEX_FILENAME,
buildPackIndexEntry,
} from "../packs/optionalPackManifest.mjs";
/**
* Locate every `node_modules/<member>` copy inside the staging tree (bounded:
* the standalone bundle only nests node_modules under the root and under
* `.build/next/`, but a defensive two-level walk costs nothing on ~1k dirs).
*
* @param {string} stagingRoot
* @param {string} member package name (scoped names keep their slash)
* @returns {string[]} absolute member dir paths found
*/
export function findMemberDirs(stagingRoot, member) {
const rel = member.split("/").join(path.sep);
const found = [];
const visit = (dir, depth) => {
if (depth > 3) return;
const candidate = path.join(dir, "node_modules", rel);
if (fs.existsSync(candidate)) found.push(candidate);
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "node_modules") continue;
if (entry.name.startsWith(".") || entry.name === "dist") continue;
visit(path.join(dir, entry.name), depth + 1);
}
};
visit(stagingRoot, 0);
return found;
}
/** @returns {{removedFiles: number, removedBytes: number}} */
function moveTree(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.renameSync(src, dest);
} catch {
fs.cpSync(src, dest, { recursive: true });
fs.rmSync(src, { recursive: true, force: true });
}
let files = 0;
let bytes = 0;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else {
files++;
bytes += fs.statSync(full).size;
}
}
};
walk(dest);
return { removedFiles: files, removedBytes: bytes };
}
function tarPack(packOutDir, tarballPath) {
// bsdtar ships with macOS, Linux images, and Windows runners (System32\tar.exe).
const result = spawnSync(
process.platform === "win32" ? "tar.exe" : "tar",
["-czf", tarballPath, "-C", packOutDir, "node_modules"],
{ stdio: "pipe" }
);
if (result.status !== 0) {
throw new Error(
`optional-pack tar failed for ${path.basename(tarballPath)} (exit ${result.status})`
);
}
}
/**
* Stage all optional packs out of the Electron bundle.
*
* @param {{stagingRoot: string, packsOutDir: string, emitTarballs?: boolean, log?: (msg: string) => void}} opts
* @returns {{index: object, packs: {name: string, removedFiles: number, removedBytes: number, tarball?: string}[]}}
*/
export async function stageOptionalPacks({
stagingRoot,
packsOutDir,
emitTarballs = process.env.OMNIROUTE_OPTIONAL_PACK_TAR !== "0",
log = () => {},
}) {
const packsOut = [];
const indexPacks = [];
for (const pack of OPTIONAL_PACKS) {
const packOutDir = path.join(packsOutDir, pack.name);
let removedFiles = 0;
let removedBytes = 0;
let stagedMembers = 0;
for (const member of pack.packages) {
const memberDirs = findMemberDirs(stagingRoot, member.name);
if (memberDirs.length === 0) {
// Fail-open: a member absent from the bundle (dependency-graph change,
// pruning by an earlier stage) must not break packaging. It is simply
// not part of the staged pack; `buildPackIndexEntry` below refuses to
// index a pack with missing members, so such a pack is skipped wholly.
log(`[optional-packs] member not found in staging tree (skipped): ${member.name}`);
continue;
}
const dest = path.join(packOutDir, "node_modules", ...member.name.split("/"));
const stats = moveTree(memberDirs[0], dest);
// Any duplicate copies (nested `.build/next/node_modules`) are deleted:
// they would ship member bytes inside the installer again.
for (const extra of memberDirs.slice(1)) {
fs.rmSync(extra, { recursive: true, force: true });
}
removedFiles += stats.removedFiles;
removedBytes += stats.removedBytes;
stagedMembers++;
}
if (stagedMembers !== pack.packages.length) {
log(
`[optional-packs] pack "${pack.name}" incomplete (${stagedMembers}/${pack.packages.length}) — not indexed`
);
continue;
}
const indexEntry = await buildPackIndexEntry(pack, path.join(packOutDir, "node_modules"));
indexPacks.push(indexEntry);
let tarball;
if (emitTarballs) {
tarball = path.join(packsOutDir, indexEntry.tarball);
tarPack(packOutDir, tarball);
}
packsOut.push({ name: pack.name, removedFiles, removedBytes, tarball });
log(
`[optional-packs] staged "${pack.name}": ${removedFiles} files, ${(removedBytes / 1024 / 1024).toFixed(1)} MB out of the desktop bundle`
);
}
const index = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
packs: indexPacks,
};
fs.writeFileSync(
path.join(stagingRoot, PACK_INDEX_FILENAME),
`${JSON.stringify(index, null, 2)}\n`
);
log(`[optional-packs] wrote ${PACK_INDEX_FILENAME} (${indexPacks.length} pack(s))`);
return { index, packs: packsOut };
}

View File

@@ -41,13 +41,19 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
"head-response-guard.cjs",
"http-method-guard.cjs",
"open-sse/mcp-server/server.js",
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js",
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
"src/lib/usage/callLogArtifactWorker.js",
"package.json",
"peer-stamp.mjs",
"main-server-timeouts.mjs",
// server-ws.mjs import (sd_notify helper) — enforced by the closure test
// tests/unit/pack-artifact-server-ws-closure.test.ts.
"systemd-notify.mjs",
"responses-ws-proxy.mjs",
"bin/chatgpt-web-codex-mcp.mjs",
"scripts/dev/sync-env.mjs",
"scripts/dev/tls-options.mjs",
"server.js",
@@ -86,13 +92,19 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"LICENSE",
"README.md",
"THIRD_PARTY_NOTICES.md",
"bin/aliasResolver.mjs",
"bin/chatgpt-web-codex-mcp.mjs",
// #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL
// js/incomplete-url-substring-sanitization (the old code built a
// `data:text/javascript,...` URL dynamically). Loaded via pathToFileURL() at
// runtime; shipped via package.json "files", so it must be allowed here.
"bin/aliasResolverHook.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
@@ -117,6 +129,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
// shipped via package.json "files", so it must be allowed in the tarball.
"open-sse/utils/setupPolyfill.ts",
"package.json",
"scripts/build/assembleStandalone.mjs",
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/build-next-isolated.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/build/native-binary-compat.mjs",
@@ -126,8 +141,15 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
// #7802: imported by scripts/build/postinstall.mjs to repair tls-client-node's
// native binary (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web transport).
"scripts/build/fixTlsClientNodeBinary.mjs",
// #8859: imported by scripts/build/postinstall.mjs to repair playwright-core's
// browser resolution on Termux/Android (no glibc, no bundled browsers).
"scripts/build/fixPlaywrightAndroid.mjs",
// #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration).
"scripts/build/runtime-env.mjs",
// #10382: imported at runtime by bin/cli/commands/packs.mjs (optional ML/browser
// runtime pack management) — shipped via package.json "files", so must be allowed.
"scripts/packs/optionalPackInstaller.mjs",
"scripts/packs/optionalPackManifest.mjs",
"scripts/build/sync-env.mjs",
"scripts/dev/responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
@@ -157,12 +179,16 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"dist/open-sse/services/compression/engines/rtk/filters/generic-output.json",
"dist/src/lib/usage/callLogArtifactWorker.js",
"dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js",
"dist/open-sse/services/compression/rules/en/filler.json",
"dist/server.js",
"dist/server-ws.mjs",
"dist/responses-ws-proxy.mjs",
"dist/peer-stamp.mjs",
"dist/main-server-timeouts.mjs",
// server-ws.mjs import (sd_notify helper) — enforced by the closure test.
"dist/systemd-notify.mjs",
"dist/http-method-guard.cjs",
// #5452: regression guard — make check:pack-artifact fail loudly if the TLS
// opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball.
@@ -177,9 +203,14 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
// tests/unit/pack-artifact-entrypoint-closures.test.ts).
"bin/cli/data-dir.mjs",
"bin/cli/utils/ensureAndroidCacheDir.mjs",
"bin/cli/utils/parseEnvValue.mjs",
"bin/cli/utils/storageKeyProvision.mjs",
"bin/cli/utils/versionFastPath.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
// #7808: aliasResolver + its hook file. bin/omniroute.mjs imports
@@ -195,6 +226,10 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/runtime-env.mjs",
// #10382: runtime imports of bin/cli/commands/packs.mjs (optional packs CLI) —
// listed REQUIRED so their absence from the tarball fails loudly.
"scripts/packs/optionalPackInstaller.mjs",
"scripts/packs/optionalPackManifest.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",
];
@@ -209,6 +244,72 @@ export function normalizeArtifactPath(filePath: string): string {
.replace(/\/{2,}/g, "/");
}
/** Extract complete JSON values from npm's mixed stdout/stderr-style output. */
export function parseJsonValuesOutput(output: string): unknown[] {
const values: unknown[] = [];
for (let start = 0; start < output.length; start++) {
if (output[start] !== "[" && output[start] !== "{") continue;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let end = start; end < output.length; end++) {
const char = output[end];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') {
inString = true;
} else if (char === "[" || char === "{") {
stack.push(char);
} else if (char === "]" || char === "}") {
const expectedOpen = char === "]" ? "[" : "{";
if (stack.at(-1) !== expectedOpen) break;
stack.pop();
if (stack.length === 0) {
try {
const parsed: unknown = JSON.parse(output.slice(start, end + 1));
values.push(parsed);
start = end;
} catch {
// This bracket pair was not a complete JSON value; continue scanning.
}
break;
}
}
}
}
return values;
}
/** Extract the first matching JSON array from npm's mixed stdout/stderr-style output. */
export function parseJsonArrayOutput(
output: string,
matches: (parsed: unknown[]) => boolean = () => true
): unknown[] {
const parsed = parseJsonValuesOutput(output).find(
(value): value is unknown[] => Array.isArray(value) && matches(value)
);
if (!parsed) throw new Error("Expected a valid JSON array in command output.");
return parsed;
}
/**
* Paths that are NEVER publishable, whatever the allowlist says.
*
* Existence reason: the allowlist grants whole prefixes (e.g.
* `@omniroute/opencode-provider/`), so a nested `node_modules` inside an allowed
* prefix used to be authorized by it. That shipped 79 MB of devDependencies
* (tsup/esbuild/typescript) — 80% of the tarball — whenever the publish ran from
* a machine where someone had installed inside that subpackage. `files[]` in
* package.json now excludes it at the source; this is the gate that FAILS if it
* ever comes back instead of silently allowing it.
*/
export const PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS: string[] = ["node_modules"];
export function findUnexpectedArtifactPaths(
filePaths: string[],
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
@@ -216,13 +317,17 @@ export function findUnexpectedArtifactPaths(
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
const hasForbiddenSegment = (filePath: string): boolean =>
filePath.split("/").some((segment) => PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS.includes(segment));
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter(
(filePath) =>
!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix))
hasForbiddenSegment(filePath) ||
(!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix)))
)
.sort();
}

View File

@@ -16,6 +16,8 @@
* - better-sqlite3 (SQLite bindings)
* - wreq-js (TLS client for OAuth providers)
* - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web)
* - sql.js (WASM SQLite fallback runtime)
* - node-machine-id (local CLI machine-token server runtime)
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
@@ -24,7 +26,16 @@
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802
*/
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -32,10 +43,62 @@ import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs";
import { fixPlaywrightAndroid } from "./fixPlaywrightAndroid.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const requireFromPackage = createRequire(join(ROOT, "package.json"));
/**
* Patch node-gyp's common.gypi to include the android_ndk_path variable.
*
* On Termux/Android, node-gyp's bundled common.gypi (in ~/.cache/node-gyp/<version>/)
* does not define the `android_ndk_path` variable that the build system expects.
* Setting GYP_DEFINES="android_ndk_path=''" is not enough because common.gypi
* is parsed separately and the variable must be declared in the 'variables' section.
*
* This function finds and patches the common.gypi for the current Node.js version,
* adding `'android_ndk_path%': ''` to the variables block. The patch is idempotent.
*/
function patchNodeGypCommonGypi() {
try {
const nodeVersion = process.version; // e.g. "v26.4.0"
const gypDir = join(
process.env.HOME || process.env.USERPROFILE || "/root",
".cache",
"node-gyp",
nodeVersion.replace(/^v/, "")
);
const commonGypi = join(gypDir, "include", "node", "common.gypi");
if (!existsSync(commonGypi)) {
console.warn(` ⚠️ common.gypi not found at ${commonGypi}, skipping patch`);
return;
}
let content = readFileSync(commonGypi, "utf8");
// Check if already patched
if (content.includes("android_ndk_path")) {
return;
}
// Find the variables section and add android_ndk_path
// The pattern is: 'variables': { 'node_use_openssl%': ... }
// We insert our variable right after the opening of the variables block
const variablesMatch = content.match(/('variables'\s*:\s*\{)/);
if (variablesMatch) {
const insertPos = content.indexOf(variablesMatch[0]) + variablesMatch[0].length;
content =
content.slice(0, insertPos) + "\n 'android_ndk_path%': ''," + content.slice(insertPos);
writeFileSync(commonGypi, content, "utf8");
console.log(` ✅ Patched common.gypi for Android at ${commonGypi}`);
}
} catch (err) {
console.warn(` ⚠️ Could not patch common.gypi: ${err.message}`);
}
}
const appBinary = join(
ROOT,
@@ -148,6 +211,9 @@ async function fixBetterSqliteBinary() {
const env = { ...process.env };
if (isAndroid) {
env.GYP_DEFINES = "android_ndk_path=''";
// Patch node-gyp's common.gypi to include android_ndk_path variable
// so the gyp build system doesn't fail with "Unknown variable"
patchNodeGypCommonGypi();
}
execSync(rebuildCmd, {
@@ -345,10 +411,63 @@ async function ensureLlmlinguaOptionals() {
}
}
/**
* Preflight check for development installs (when standalone dist/ bundle is not present).
* Warns or errors if critical native dependencies like better-sqlite3 were skipped by npm >= 11
* allowScripts restrictions.
*/
async function verifyDevNativeModules() {
if (hasStandaloneAppBundle(ROOT)) {
return;
}
const criticalModules = [
{ name: "better-sqlite3", fatal: true },
{ name: "esbuild", fatal: true },
];
for (const { name, fatal } of criticalModules) {
if (!existsSync(join(ROOT, "node_modules", name))) {
const level = fatal ? "🔴 CRITICAL" : "⚠️ WARNING";
console.error(`\n ${level}: '${name}' is missing from node_modules/`);
console.error(` This usually happens with npm ≥ 11, which blocks install`);
console.error(` scripts for optional dependencies by default.`);
console.error(`\n Fix options:`);
console.error(` 1. npm approve-scripts ${name} && npm install`);
console.error(` 2. npm pack ${name} && tar -xzf ${name}-*.tgz -C node_modules`);
console.error(` && mv node_modules/package node_modules/${name}`);
console.error(` 3. Downgrade to npm 10: npm install -g npm@10\n`);
}
}
}
async function ensureStandaloneRuntimePackages() {
for (const packageName of ["sql.js", "node-machine-id"]) {
let source;
try {
source = dirname(dirname(requireFromPackage.resolve(packageName)));
} catch {
console.warn(` ⚠️ ${packageName} could not be resolved from the npm install.`);
continue;
}
const destination = join(ROOT, "dist", "node_modules", packageName);
try {
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination, { recursive: true, force: true });
console.log(`${packageName} copied to standalone dist/node_modules.`);
} catch (err) {
console.warn(` ⚠️ Could not copy ${packageName}: ${err.message}`);
}
}
}
await verifyDevNativeModules();
await fixBetterSqliteBinary();
await fixWreqJsBinary();
await fixTlsClientNodeBinary({ rootDir: ROOT });
await fixPlaywrightAndroid({ rootDir: ROOT });
await ensureSwcHelpers();
await ensureStandaloneRuntimePackages();
await ensureLlmlinguaOptionals();
await syncProjectEnv();

View File

@@ -1,11 +1,12 @@
#!/usr/bin/env node
import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { buildRebuildSpawnPlan } from "./electronRebuildPlan.mjs";
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
import { stageOptionalPacks } from "./optionalPackStaging.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -89,9 +90,7 @@ function removeNativeModules(baseDir, prefixes = ["keytar"]) {
// user machine as "Internal Server Error" on every route.
function assertNoStaleHashedNatives(baseDir, prefixes) {
if (!existsSync(baseDir)) return;
const leftovers = readdirSync(baseDir).filter((dir) =>
prefixes.some((p) => dir.startsWith(p))
);
const leftovers = readdirSync(baseDir).filter((dir) => prefixes.some((p) => dir.startsWith(p)));
if (leftovers.length > 0) {
throw new Error(
`[electron] stale native module copies survived cleanup in ${baseDir}: ` +
@@ -101,77 +100,43 @@ function assertNoStaleHashedNatives(baseDir, prefixes) {
}
}
// --- Electron-UNIQUE: rebuild better-sqlite3 against the Electron ABI --------
// --- Electron-UNIQUE: verify better-sqlite3 Node-API prebuilds ----------------
//
// The `npm ci` at the repo root compiles better-sqlite3 for the CI *Node* ABI
// (e.g. 137 for Node 24). The packaged app runs its Next.js server via
// ELECTRON_RUN_AS_NODE, so it needs the *Electron* ABI (146 for electron 42,
// 148 for electron 43). We cannot rely on electron-builder's @electron/rebuild
// here: it searches `electron/node_modules` (where better-sqlite3 does not live)
// and, with the default prebuild path, tries to fetch a prebuilt binary — but
// better-sqlite3@12.11.1 only ships prebuilds up to electron-v146, so electron
// 43 (v148) silently gets no rebuild and the app dies with "Nenhum driver
// SQLite disponível — better-sqlite3 (falhou)".
// better-sqlite3 >= 13 ships Node-API (NAPI_VERSION=10) prebuilds for every
// platform we package (darwin/linux/linuxmusl/win32 × x64/arm64) inside the
// npm tarball. Node-API addons are ABI-independent, so the same prebuild runs
// under plain Node (CI, CLI) and under the packaged app's ELECTRON_RUN_AS_NODE
// server (verified against electron 43 / NODE_MODULE_VERSION 148 — issue
// #10321 Stage 6). The historical source rebuild below existed because
// better-sqlite3@12 only shipped prebuilds up to electron-v146 and electron 43
// (v148) silently got no binary; v13 makes that obsolete.
//
// Instead we copy the *full* module (source + binding.gyp) from the root into
// the standalone and compile it from source against the Electron headers, so
// `bindings` finds a correct build/Release/better_sqlite3.node regardless of
// prebuild availability. Robust to any current/future electron version.
// Instead of compiling from source on every build (tens of seconds to minutes
// per platform), we fail fast when the prebuild for the CURRENT build platform
// is missing — a missing prebuild must kill the build here, not the app on a
// user machine with "Nenhum driver SQLite disponível — better-sqlite3 (falhou)".
function readElectronVersion() {
const pkg = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8"));
const raw = pkg.devDependencies?.electron || pkg.dependencies?.electron || "";
return String(raw).replace(/^[\^~]/, "");
}
function rebuildBetterSqlite3ForElectron(standaloneNodeModules) {
const srcMod = join(ROOT, "node_modules", "better-sqlite3");
if (!existsSync(srcMod)) {
console.warn("[electron] better-sqlite3 not found at repo root — skipping ABI rebuild.");
function verifyBetterSqlite3Prebuilds(standaloneNodeModules) {
const destMod = join(standaloneNodeModules, "better-sqlite3");
if (!existsSync(destMod)) {
console.warn("[electron] better-sqlite3 not found in standalone — skipping prebuild check.");
return;
}
const electronVersion = readElectronVersion();
if (!electronVersion) {
throw new Error("[electron] could not resolve electron version for better-sqlite3 rebuild.");
}
const destMod = join(standaloneNodeModules, "better-sqlite3");
// copyNatives only copies build/; we need the full module (src + binding.gyp)
// to compile from source. Overwrite the copied Node-ABI build in the process.
cpSync(srcMod, destMod, { recursive: true, force: true });
rmSync(join(destMod, "build"), { recursive: true, force: true });
console.log(`[electron] rebuilding better-sqlite3 against electron ${electronVersion} ABI…`);
const plan = buildRebuildSpawnPlan(process.platform);
const result = spawnSync(
plan.command,
plan.args,
{
cwd: destMod,
stdio: "inherit",
// .cmd shims must go through a shell on Windows (CVE-2024-27980 hardening
// makes a shell-less spawn fail with status null); args are fixed literals.
shell: plan.shell,
// Compile against the Electron headers (not Node's) so the .node lands in
// build/Release with the Electron NODE_MODULE_VERSION. No shell interpolation.
env: {
...process.env,
npm_config_runtime: "electron",
npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers",
npm_config_arch: process.arch,
npm_config_build_from_source: "true",
},
}
);
if (result.status !== 0) {
throw new Error(
`[electron] better-sqlite3 rebuild against electron ${electronVersion} failed (exit ${result.status}).`
);
}
// Drop the now-unneeded compile inputs to keep the packaged app lean.
for (const dir of ["deps", "src", "build/Debug", "build/obj.target"]) {
// Fail fast when the loader would find no prebuild for THIS build platform.
// Mirrors better-sqlite3's own lib/binding.js selection logic.
const reportHeader = process.report?.getReport?.().header;
assertSqlitePrebuildExists(destMod, process.platform, process.arch, reportHeader);
// Drop compile inputs and stale Node-ABI build outputs to keep the packaged
// app lean and to guarantee the loader resolves the prebuild, not a leftover
// build/Release/better_sqlite3.node compiled for a different ABI.
for (const dir of ["build", "deps", "src"]) {
rmSync(join(destMod, dir), { recursive: true, force: true });
}
console.log(
`[electron] better-sqlite3 Node-API prebuilds verified for ${process.platform}-${process.arch}.`
);
}
function logContextualError(error) {
@@ -205,15 +170,23 @@ assembleStandalone({
materializeSymlinks: true,
});
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
if (docsPrune.removedFiles > 0) {
console.log(
`[electron] pruned ${docsPrune.removedFiles} authoring doc file(s) ` +
`(${docsPrune.removedBytes} bytes) from the staging bundle`
);
}
// Electron-UNIQUE post-assembly steps
removeGeneratedElectronArtifacts();
// Rebuild better-sqlite3 from source against the Electron ABI in the primary
// node_modules (where the standalone server resolves it). keytar is still
// stripped so electron-builder's @electron/rebuild handles it (it has electron
// prebuilds); also drop any stray Node-ABI better-sqlite3 under .next/node_modules
// so it cannot shadow the rebuilt one.
rebuildBetterSqlite3ForElectron(join(ELECTRON_STANDALONE_DIR, "node_modules"));
// Verify better-sqlite3 Node-API prebuilds in the primary node_modules (where
// the standalone server resolves it). keytar is still stripped so
// electron-builder's @electron/rebuild handles it (it has electron prebuilds);
// also drop any stray better-sqlite3 under .next/node_modules so it cannot
// shadow the prebuild-backed one.
verifyBetterSqlite3Prebuilds(join(ELECTRON_STANDALONE_DIR, "node_modules"));
removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules"), ["keytar"]);
removeNativeModules(join(ELECTRON_STANDALONE_DIR, NEXT_DIST_DIR, "node_modules"), [
"better-sqlite3",
@@ -229,6 +202,18 @@ assertNoStaleHashedNatives(join(ELECTRON_STANDALONE_DIR, NEXT_DIST_DIR, "node_mo
"keytar",
]);
// Stage 7 (issue #10321): move the optional ML/browser dependency closure out of
// the desktop bundle into checksummed, versioned packs under
// `.build/optional-packs/` (+ tarballs) and emit `optional-packs.index.json` at
// the bundle root. Runs after the native-module steps so it only ever sees the
// final staging tree. Fail-open per member (see optionalPackStaging.mjs).
const OPTIONAL_PACKS_OUT_DIR = join(ROOT, ".build", "optional-packs");
await stageOptionalPacks({
stagingRoot: ELECTRON_STANDALONE_DIR,
packsOutDir: OPTIONAL_PACKS_OUT_DIR,
log: (msg) => console.log(msg.replace(/^\[optional-packs\]/, "[electron]")),
});
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);

View File

@@ -27,6 +27,8 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { isNativeExecutable, resolveLocalBinEntry } from "./buildToolRunner.mjs";
import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
@@ -39,6 +41,51 @@ const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
// On Windows the npm/npx entry points are `.cmd` shims, and Node >= 20 refuses to
// spawn a `.cmd` without a shell (EINVAL, from the CVE-2024-27980 hardening). On
// Node 24 that makes every `execFileSync(NPX_BIN, ...)` in this script fail, which
// silently skipped the MITM utilities, the MCP server bundle, the LLMLingua worker
// and the OpenCode plugin while the build still reported success.
//
// `shell: true` would fix the spawn but disables argument escaping (DEP0190), so it
// is only the last resort. Preferred order: run the tool's own JS entry point with
// this Node binary — no shim, no shell, nothing to escape. `resolveLocalBinEntry()`
// and `isNativeExecutable()` implement that resolution and now live in
// buildToolRunner.mjs, shared with the plain-`node` build scripts.
/**
* Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the
* tool lives in the local dependency tree; when it is not installed there the call
* falls back to the Node-resolved `npx` entry point, and only then to the shim.
*/
function runBuildTool(
packageName: string,
binName: string,
args: readonly string[],
options: Parameters<typeof execFileSync>[2]
): void {
const localEntry = resolveLocalBinEntry(packageName, binName);
if (localEntry) {
if (isNativeExecutable(localEntry)) {
execFileSync(localEntry, [...args], options);
return;
}
execFileSync(process.execPath, [localEntry, ...args], options);
return;
}
const npxEntry = resolveBundledNpmEntry("npx-cli.js");
if (npxEntry) {
execFileSync(process.execPath, [npxEntry, binName, ...args], options);
return;
}
// Last resort. The arguments here are static build literals, never user input,
// so the missing escaping under `shell` is not an injection surface.
execFileSync(NPX_BIN, [binName, ...args], {
...options,
shell: process.platform === "win32",
});
}
const DIST_DIR = join(ROOT, "dist");
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
@@ -205,7 +252,7 @@ if (existsSync(mitmSrc)) {
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
try {
execFileSync(NPX_BIN, ["tsc", "-p", "tsconfig.mitm.tmp.json"], {
runBuildTool("typescript", "tsc", ["-p", "tsconfig.mitm.tmp.json"], {
cwd: ROOT,
stdio: "inherit",
});
@@ -235,10 +282,10 @@ if (existsSync(mcpSrcFile)) {
console.log(" 🔨 Bundling MCP Server (TypeScript → JavaScript)...");
mkdirSync(mcpDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
runBuildTool(
"esbuild",
"esbuild",
[
"esbuild",
"open-sse/mcp-server/server.ts",
"--bundle",
"--platform=node",
@@ -254,11 +301,69 @@ if (existsSync(mcpSrcFile)) {
}
}
// ── Step 8.6: Bundle LLMLingua ONNX worker ────────────────────────────
const chatGptWebCodexMcpSrcFile = join(
ROOT,
"open-sse",
"vendor",
"codex-chatgpt-web",
"adapters",
"chatgpt-web",
"mcp-server.ts"
);
const chatGptWebCodexMcpDestFile = join(
DIST_DIR,
"open-sse",
"vendor",
"codex-chatgpt-web",
"adapters",
"chatgpt-web",
"mcp-server.js"
);
if (existsSync(chatGptWebCodexMcpSrcFile)) {
console.log(" 🔨 Bundling ChatGPT Web (Codex) MCP bridge...");
mkdirSync(dirname(chatGptWebCodexMcpDestFile), { recursive: true });
execFileSync(
NPX_BIN,
[
"esbuild",
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
}
// ── Step 8.6: Bundle call-log artifact worker ────────────────────────
const callLogWorkerSrc = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts");
const callLogWorkerDest = join(DIST_DIR, "src", "lib", "usage", "callLogArtifactWorker.js");
if (!existsSync(callLogWorkerSrc)) {
throw new Error("Required call-log artifact worker source is missing");
}
console.log(" 🔨 Bundling call-log artifact worker...");
mkdirSync(dirname(callLogWorkerDest), { recursive: true });
runBuildTool(
"esbuild",
"esbuild",
[
"src/lib/usage/callLogArtifactWorker.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/src/lib/usage/callLogArtifactWorker.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
// ── Step 8.6a: Bundle LLMLingua ONNX worker ───────────────────────────
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// dynamically imported at runtime, and the worker fail-opens if any is absent.
const llmWorkerSrc = join(
ROOT,
@@ -281,10 +386,10 @@ if (existsSync(llmWorkerSrc)) {
console.log(" 🔨 Bundling LLMLingua ONNX worker (TypeScript → JavaScript)...");
mkdirSync(llmWorkerDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
runBuildTool(
"esbuild",
"esbuild",
[
"esbuild",
"open-sse/services/compression/engines/llmlingua/onnxWorker.ts",
"--bundle",
"--platform=node",
@@ -309,10 +414,10 @@ const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
if (existsSync(cliSrcFile)) {
console.log(" 🔨 Bundling CLI Entrypoint (TypeScript → JavaScript)...");
try {
execFileSync(
NPX_BIN,
runBuildTool(
"esbuild",
"esbuild",
[
"esbuild",
"bin/omniroute.ts",
"--bundle",
"--platform=node",
@@ -349,13 +454,68 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
// The plugin's node_modules is gitignored, so a fresh CI checkout
// ALWAYS installs here. The registry CDN is intermittently flaky
// (onnxruntime-class ETIMEDOUTs to the Microsoft CDN have repeatedly
// stalled CI npm steps for 20+ minutes), and npm's unbounded fetch
// retries turn a stalled connection into a hang that eats the whole
// job budget. Bound the fetch and retry the install a few times:
// transient network failures fail fast and recover instead of hanging.
const npmEntry = resolveBundledNpmEntry("npm-cli.js");
const installArgs = [
"install",
"--no-audit",
"--no-fund",
"--fetch-retries=2",
"--fetch-retry-mintimeout=2000",
"--fetch-retry-maxtimeout=30000",
"--fetch-timeout=60000",
];
const runPluginInstall = () => {
if (npmEntry) {
execFileSync(process.execPath, [npmEntry, ...installArgs], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else if (process.platform !== "win32") {
// No bundled npm entry found (non-standard Node layout). Plain `npm` is
// safe here — the .cmd-shim hazard #8858 guards against is Windows-only.
execFileSync("npm", installArgs, {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else {
throw new Error(
"npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim."
);
}
};
const sleepSync = (ms: number) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
let installError: any = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
if (attempt > 1) {
console.log(
` 🔄 @omniroute/opencode-plugin npm install retry (attempt ${attempt}/3)`
);
}
runPluginInstall();
installError = null;
break;
} catch (err: any) {
installError = err;
if (attempt < 3) {
console.warn(
` ⚠️ plugin npm install failed (attempt ${attempt}/3): ${err?.message ?? String(err)} — retrying in 10s`
);
sleepSync(10_000);
}
}
}
if (installError) throw installError;
}
execFileSync(NPX_BIN, ["tsup"], {
runBuildTool("tsup", "tsup", [], {
cwd: opencodePluginSrc,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },

View File

@@ -0,0 +1,40 @@
import { existsSync } from "fs";
import { dirname, join } from "path";
/** Injectable seams for {@link resolveBundledNpmEntry} (all default to the real ones). */
export interface ResolveNpmEntryDeps {
execPath?: string;
/** `process.env.npm_execpath` — set by npm itself when running under `npm run`. */
npmExecPath?: string;
exists?: (p: string) => boolean;
}
/**
* Locate `npm-cli.js` / `npx-cli.js` so build steps can run npm/npx through
* `process.execPath` directly and never touch a `.cmd` shim (#8858), covering
* BOTH install layouts:
* - Windows: `<dir(node.exe)>\node_modules\npm\bin\<name>` (npm beside the binary)
* - POSIX: `<dir(node)>/../lib/node_modules/npm/bin/<name>` (node under `<prefix>/bin`,
* the shape of GitHub hosted runners, nvm and system installs)
* When the script itself runs under `npm run`, npm exports `npm_execpath` pointing at
* its own npm-cli.js — the most reliable source, tried first (npx-cli.js is its sibling).
*/
export function resolveBundledNpmEntry(
name: "npm-cli.js" | "npx-cli.js",
deps: ResolveNpmEntryDeps = {}
): string | null {
const execPath = deps.execPath ?? process.execPath;
const exists = deps.exists ?? existsSync;
const npmExecPath = deps.npmExecPath ?? process.env.npm_execpath;
const binDir = dirname(execPath);
const candidates: string[] = [];
if (npmExecPath) candidates.push(join(dirname(npmExecPath), name));
candidates.push(join(binDir, "node_modules", "npm", "bin", name));
candidates.push(join(binDir, "..", "lib", "node_modules", "npm", "bin", name));
for (const candidate of candidates) {
if (exists(candidate)) return candidate;
}
return null;
}

View File

@@ -49,6 +49,59 @@ export function envHasExplicitHeapFlag(env) {
return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG);
}
/** Last `--max-old-space-size=` value in NODE_OPTIONS, or null if absent. */
export function parseNodeOptionsHeapMb(nodeOptions) {
const matches = [...String(nodeOptions || "").matchAll(/--max-old-space-size=(\d+)/g)];
if (matches.length === 0) return null;
const parsed = Number.parseInt(matches[matches.length - 1][1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* True when OMNIROUTE_MEMORY_MB is an explicit in-range integer (not the
* unset/invalid fallback). Docker images set this; Compose may also set
* NODE_OPTIONS — #10353 needs to know both knobs were intentionally present.
*/
export function envHasExplicitOmnirouteMemoryMb(env) {
const sourceEnv = arguments.length === 0 ? process.env : env;
const parsed = Number.parseInt(String(sourceEnv?.OMNIROUTE_MEMORY_MB ?? ""), 10);
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384;
}
/**
* Docker `run-standalone.mjs` appends `--max-old-space-size` from
* OMNIROUTE_MEMORY_MB. V8 last-flag semantics mean that appended value wins
* over an earlier NODE_OPTIONS heap. Warn once when both are set and disagree
* so env dumps stop looking like NODE_OPTIONS is in effect (#10353).
*
* @returns {boolean} true when a warn was emitted
*/
export function warnConflictingHeapLimits(env, omnirouteMb, log = console.warn) {
const nodeMb = parseNodeOptionsHeapMb(env?.NODE_OPTIONS);
if (nodeMb == null || !envHasExplicitOmnirouteMemoryMb(env)) return false;
if (nodeMb === omnirouteMb) return false;
log(
`[omniroute] heap limit conflict: OMNIROUTE_MEMORY_MB=${omnirouteMb} disagrees with NODE_OPTIONS --max-old-space-size=${nodeMb}. ` +
`run-standalone.mjs / Docker appends OMNIROUTE_MEMORY_MB last, so the effective V8 heap is ${omnirouteMb} MB. ` +
`Set only OMNIROUTE_MEMORY_MB (recommended) or make both values match.`
);
return true;
}
/**
* NODE_OPTIONS string for Docker / run-standalone.mjs.
* Explicit OMNIROUTE_MEMORY_MB always appends (wins). Otherwise keep an
* existing NODE_OPTIONS heap flag (#5238). Otherwise append the fallback.
*/
export function buildStandaloneNodeOptions(env = process.env, omnirouteMb) {
const existing = String(env?.NODE_OPTIONS || "").trim();
if (envHasExplicitOmnirouteMemoryMb(env)) {
return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim();
}
if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing;
return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim();
}
/**
* Assemble the NODE_OPTIONS string for the spawned server, preserving any flags
* the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY
@@ -86,6 +139,20 @@ export function buildNodeHeapArgs(env = process.env, memoryLimit) {
return envHasExplicitHeapFlag(env) ? [] : [`${MAX_OLD_SPACE_FLAG}=${memoryLimit}`];
}
/**
* Build the complete argument list for spawning the Node.js server runtime.
* Prefer IPv4 DNS results before starting the application so undici does not
* stall on hosts whose IPv6 route silently drops outbound connections.
*
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
* @param {string} serverPath — standalone server entrypoint
* @returns {string[]}
*/
export function buildNodeRuntimeArgs(env = process.env, memoryLimit, serverPath) {
return ["--dns-result-order=ipv4first", ...buildNodeHeapArgs(env, memoryLimit), serverPath];
}
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
@@ -107,6 +174,7 @@ export function withRuntimePortEnv(env, runtimePorts) {
PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort),
HOSTNAME: env.OMNIROUTE_HOSTNAME || "0.0.0.0",
};
}

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* CLI entry for the shared Next standalone web build (issue #10321, Stage 8).
*
* One ubuntu `web-build` job runs `pack` once; every desktop matrix leg runs
* `restore` (byte-verified against the manifest) and `hydrate` (replaces
* install-machine-forked native optionals with this leg's own `npm ci` forks,
* then asserts the bundled natives can service the leg's platform/arch).
*
* Rollback: set repo variable ELECTRON_SHARED_STANDALONE=disabled and the
* workflow falls back to the legacy per-leg `npm run build` — no revert needed.
*/
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import {
buildStandaloneManifest,
verifyStandaloneManifest,
MANIFEST_VERSION,
} from "./standaloneManifest.mjs";
import { createTarGz, extractTarGz } from "./standaloneTarball.mjs";
import { hydratePlatformNatives, verifyBundledNatives } from "./hydrateNativeDeps.mjs";
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function manifestPathFor(archive) {
return `${archive}.manifest.json`;
}
/**
* Pack a web-build tree into a deterministic archive plus a byte-level
* manifest (which embeds the archive's own sha256 so transfer corruption is
* caught before extraction).
*
* @param {{dir?: string, out: string, manifest?: string}} opts
* @returns {Promise<{archive: string, manifest: string, files: number, archiveBytes: number}>}
*/
export async function runPack({ dir = ".build/next", out, manifest }) {
if (!out) throw new Error("pack requires --out <file.tar.gz>");
const rootDir = path.resolve(dir);
if (!fs.existsSync(rootDir)) {
throw new Error(`web build tree not found: ${rootDir} (did 'npm run build' run?)`);
}
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
const built = await buildStandaloneManifest(rootDir);
await createTarGz(rootDir, out);
const archiveBytes = fs.statSync(out).size;
const archiveSha = await sha256File(out);
const manifestFile = manifest ?? manifestPathFor(out);
const payload = {
version: MANIFEST_VERSION,
archive: { name: path.basename(out), bytes: archiveBytes, sha256: archiveSha },
entries: built.entries,
};
fs.writeFileSync(manifestFile, `${JSON.stringify(payload, null, 2)}\n`);
return { archive: out, manifest: manifestFile, files: built.entries.length, archiveBytes };
}
/**
* Verify + extract a packed archive into `dir`, then prove the restored tree
* matches the manifest byte-for-byte.
*
* @param {{archive: string, manifest?: string, dir?: string}} opts
* @returns {Promise<{archive: string, dir: string, files: number}>}
*/
export async function runRestore({ archive, manifest, dir = ".build/next" }) {
if (!archive) throw new Error("restore requires --archive <file.tar.gz>");
const manifestFile = manifest ?? manifestPathFor(archive);
const raw = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (raw.version !== MANIFEST_VERSION) {
throw new Error(`unsupported manifest version: ${raw.version}`);
}
const archiveBytes = fs.statSync(archive).size;
if (archiveBytes !== raw.archive.bytes) {
throw new Error(`archive size ${archiveBytes} != manifest ${raw.archive.bytes}`);
}
const archiveSha = await sha256File(archive);
if (archiveSha !== raw.archive.sha256) {
throw new Error(`archive sha256 mismatch (expected ${raw.archive.sha256.slice(0, 12)})`);
}
const destDir = path.resolve(dir);
fs.rmSync(destDir, { recursive: true, force: true });
await extractTarGz(archive, destDir);
const verdict = await verifyStandaloneManifest(destDir, raw);
if (!verdict.ok) {
throw new Error(
`restored tree failed manifest verification:\n ${verdict.errors.join("\n ")}`
);
}
return { archive, dir: destDir, files: raw.entries.length };
}
/**
* Hydrate the restored tree's node_modules with this machine's forked
* optionals and assert bundled natives cover every requested arch.
*
* @param {{standaloneNodeModules?: string, sourceNodeModules?: string, platform: string, arch: string}} opts
* `arch` accepts a comma-separated list (the linux leg ships x64+arm64).
* @returns {Promise<{replaced: string[], removed: string[], copied: string[], verified: string[]}>}
*/
export async function runHydrate({
standaloneNodeModules = ".build/next/standalone/node_modules",
sourceNodeModules = "node_modules",
platform,
arch,
}) {
if (!platform || !arch) throw new Error("hydrate requires --platform <os> --arch <a[,a2...]>");
const result = hydratePlatformNatives({
standaloneNodeModules: path.resolve(standaloneNodeModules),
sourceNodeModules: path.resolve(sourceNodeModules),
});
const verified = [];
for (const one of arch
.split(",")
.map((s) => s.trim())
.filter(Boolean)) {
const verdict = verifyBundledNatives({
nodeModulesDir: path.resolve(standaloneNodeModules),
platform,
arch: one,
});
if (!verdict.ok) {
throw new Error(
`bundled natives cannot service ${platform}/${one}:\n ${verdict.errors.join("\n ")}`
);
}
verified.push(one);
}
return { ...result, verified };
}
// ─── argv plumbing ───────────────────────────────────────────────────────────────
/** Minimal `--key value` parser (booleans: `--key` alone → true). */
export function parseArgs(argv) {
const opts = { _: [] };
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (!token.startsWith("--")) {
opts._.push(token);
continue;
}
const key = token.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
opts[key] = next;
i++;
} else {
opts[key] = true;
}
}
return opts;
}
function usage() {
return [
"usage:",
" standaloneBundle.mjs pack --out <file.tar.gz> [--dir .build/next] [--manifest <file.json>]",
" standaloneBundle.mjs restore --archive <file.tar.gz> [--manifest <file.json>] [--dir .build/next]",
" standaloneBundle.mjs hydrate --platform <os> --arch <a[,a2...]>",
" [--standalone-node-modules <dir>] [--source-node-modules <dir>]",
].join("\n");
}
async function main(argv) {
const [command = "", ...rest] = argv;
const opts = parseArgs(rest);
try {
if (command === "pack") {
const r = await runPack({ dir: opts.dir, out: opts.out, manifest: opts.manifest });
console.log(
`[standalone-bundle] packed ${r.files} entries -> ${r.archive} ` +
`(${(r.archiveBytes / 1e6).toFixed(1)} MB); manifest ${r.manifest}`
);
} else if (command === "restore") {
const r = await runRestore({ archive: opts.archive, manifest: opts.manifest, dir: opts.dir });
console.log(
`[standalone-bundle] restored ${r.files} entries from ${path.basename(r.archive)} -> ${r.dir}`
);
} else if (command === "hydrate") {
const r = await runHydrate({
standaloneNodeModules: opts["standalone-node-modules"],
sourceNodeModules: opts["source-node-modules"],
platform: opts.platform,
arch: opts.arch,
});
console.log(
`[standalone-bundle] hydrated forks: copied=${r.copied.length} replaced=${r.replaced.length} ` +
`removed=${r.removed.length}; bundled natives verified for ${r.verified.join("+")}`
);
} else {
console.error(usage());
process.exitCode = 2;
}
} catch (err) {
console.error(`[standalone-bundle] ${command || "(no command)"} failed: ${err.message}`);
process.exitCode = 1;
}
}
if (
process.argv[1] &&
import.meta.url === new URL(`file://${path.resolve(process.argv[1])}`).href
) {
await main(process.argv.slice(2));
}

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Byte-level manifest for the shared Next standalone web build (issue #10321,
* Stage 8).
*
* The desktop pipeline used to rebuild the identical Next standalone bundle
* four times (one per electron-release matrix leg). Stage 8 builds it once on
* an ubuntu runner and restores it on every leg; this module is the integrity
* contract that makes a restored tree provably identical to the built one.
*
* Deterministic by construction: entries are sorted by path, timestamps are
* never recorded, and symlinks are pinned by their target so a restored tree
* verifies even though tar extraction rewrites mtimes.
*/
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
export const MANIFEST_VERSION = 1;
/** Streamed sha256 for large native payloads (onnxruntime is ~200 MB). */
async function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function walkDir(root, current, entries) {
const children = fs.readdirSync(current, { withFileTypes: true });
// Sort for determinism: manifest of the same tree is byte-identical.
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const child of children) {
const abs = path.join(current, child.name);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (child.isSymbolicLink()) {
entries.push({ path: rel, symlink: fs.readlinkSync(abs) });
} else if (child.isDirectory()) {
walkDir(root, abs, entries);
} else if (child.isFile()) {
entries.push({ path: rel, file: abs });
}
// Other node types (fifo/socket) never appear in build output; ignoring
// them keeps the manifest shape minimal.
}
}
/**
* Build a manifest of every file and symlink under `rootDir`.
*
* @returns {Promise<{version: number, entries: {path: string, bytes: number, sha256: string, symlink?: string}[]}>}
*/
export async function buildStandaloneManifest(rootDir) {
const entries = [];
walkDir(rootDir, rootDir, entries);
const manifestEntries = [];
for (const entry of entries) {
if (entry.symlink !== undefined) {
manifestEntries.push({ path: entry.path, bytes: 0, sha256: "", symlink: entry.symlink });
continue;
}
const stat = fs.statSync(entry.file);
manifestEntries.push({
path: entry.path,
bytes: stat.size,
sha256: await sha256File(entry.file),
});
}
manifestEntries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return { version: MANIFEST_VERSION, entries: manifestEntries };
}
/**
* Verify a restored tree against a manifest built by `buildStandaloneManifest`.
* Checks existence, size, and content hash of every entry, plus that no
* unlisted files were smuggled in.
*
* @returns {Promise<{ok: true} | {ok: false, errors: string[]}>}
*/
export async function verifyStandaloneManifest(rootDir, manifest) {
const errors = [];
if (!manifest || manifest.version !== MANIFEST_VERSION) {
return { ok: false, errors: [`unsupported manifest version: ${manifest?.version}`] };
}
const listed = new Map(manifest.entries.map((e) => [e.path, e]));
for (const entry of manifest.entries) {
const abs = path.join(rootDir, ...entry.path.split("/"));
let stat;
try {
stat = fs.lstatSync(abs);
} catch {
errors.push(`${entry.path}: missing`);
continue;
}
if (entry.symlink !== undefined) {
if (!stat.isSymbolicLink()) {
errors.push(`${entry.path}: expected symlink, found regular entry`);
} else {
const target = fs.readlinkSync(abs);
if (target !== entry.symlink) {
errors.push(`${entry.path}: symlink target ${target} != ${entry.symlink}`);
}
}
continue;
}
if (!stat.isFile()) {
errors.push(`${entry.path}: expected file, found directory/symlink`);
continue;
}
if (stat.size !== entry.bytes) {
errors.push(`${entry.path}: size ${stat.size} != ${entry.bytes}`);
continue;
}
const digest = await sha256File(abs);
if (digest !== entry.sha256) {
errors.push(`${entry.path}: sha256 mismatch`);
}
}
const actual = [];
walkDir(rootDir, rootDir, actual);
const actualPaths = new Set(actual.map((e) => e.path));
for (const p of listed.keys()) actualPaths.delete(p);
if (actualPaths.size > 0) {
errors.push(`unlisted files: ${[...actualPaths].sort().slice(0, 5).join(", ")}`);
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

View File

@@ -0,0 +1,381 @@
#!/usr/bin/env node
/**
* Deterministic tar.gz primitives for the shared web build (issue #10321,
* Stage 8).
*
* Why not shell out to system tar: the restore step runs on every desktop
* matrix leg including Windows, where bsdtar's long-path behavior on deep
* node_modules trees is not guaranteed. Node's fs layer already proves it can
* produce and consume this exact tree on Windows today (the legacy per-leg
* `npm run build` writes it with the same fs), so a pure-Node reader keeps the
* extraction on the one path layer we know works.
*
* Format: ustar with GNU LongLink ('L') entries for paths > 100 chars,
* typeflag '2' for symlinks, mtime/uid/gid zeroed and modes normalized to
* 0644/0755 (exec bit only) so the archive of a given tree is byte-identical
* on every machine.
*/
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { once } from "node:events";
import { createGunzip, createGzip } from "node:zlib";
const BLOCK = 512;
function octal(value, length) {
return value.toString(8).padStart(length - 1, "0") + "\0";
}
function headerFor(name, size, typeflag, linkname = "", prefix = "", mode = 0o644) {
const buf = Buffer.alloc(BLOCK, 0);
buf.write(name.slice(0, 100), 0, 100, "utf8");
buf.write(octal(typeflag === "5" ? 0o755 : mode, 8), 100);
buf.write(octal(0, 8), 108); // uid
buf.write(octal(0, 8), 116); // gid
buf.write(octal(size, 12), 124);
buf.write(octal(0, 12), 136); // mtime = 0 for determinism
buf.write(" ", 148); // checksum placeholder: spaces
buf.write(typeflag, 156);
buf.write(linkname.slice(0, 100), 157, 100, "utf8");
buf.write("ustar\0", 257, 6, "utf8");
buf.write("00", 263, 2, "utf8");
buf.write(prefix.slice(0, 155), 345, 155, "utf8");
let sum = 0;
for (const byte of buf) sum += byte;
buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
return buf;
}
function dataPad(size) {
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
return Buffer.alloc(pad, 0);
}
function longLinkEntry(name) {
const payload = Buffer.from(name + "\0", "utf8");
return Buffer.concat([
headerFor("././@LongLink", payload.length, "L"),
payload,
dataPad(payload.length),
]);
}
/** Emit header (with LongLink/prefix handling) for one entry. */
function entryHeader(relPath, size, typeflag, linkname, mode) {
const out = [];
if (relPath.length > 100) {
const slash = relPath.slice(0, 155).lastIndexOf("/");
const prefix = slash > 0 ? relPath.slice(0, slash) : "";
const name = prefix ? relPath.slice(slash + 1) : relPath;
if (name.length > 100) {
out.push(longLinkEntry(relPath));
name = relPath.slice(0, 100);
}
out.push(headerFor(name, size, typeflag, linkname, prefix, mode));
} else {
out.push(headerFor(relPath, size, typeflag, linkname, undefined, mode));
}
return Buffer.concat(out);
}
function* walkFiles(root, current = root) {
const children = fs
.readdirSync(current, { withFileTypes: true })
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const child of children) {
const abs = path.join(current, child.name);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (child.isSymbolicLink()) {
yield { rel, symlink: fs.readlinkSync(abs) };
} else if (child.isDirectory()) {
yield* walkFiles(root, abs);
} else if (child.isFile()) {
yield { rel, abs };
}
}
}
/** Write a buffer, respecting gzip backpressure. */
async function writeWithBackpressure(stream, buf) {
if (!stream.write(buf)) await once(stream, "drain");
}
/** Stream one file's bytes into the archive (no whole-file buffering). */
function pipeFileInto(gz, failure, abs) {
return new Promise((resolve, reject) => {
const stream = createReadStream(abs, { autoClose: true });
const onDrain = () => stream.resume();
const detach = () => gz.removeListener("drain", onDrain);
stream.on("error", (err) => {
detach();
reject(err);
});
stream.on("data", (chunk) => {
if (!gz.write(chunk)) stream.pause();
});
gz.on("drain", onDrain);
stream.on("end", () => {
detach();
resolve();
});
});
}
/** Pack `srcDir` into a deterministic gzipped tarball at `outFile`. */
export async function createTarGz(srcDir, outFile) {
const out = createWriteStream(outFile);
const gz = createGzip({ level: 1 });
gz.pipe(out);
const failure = new Promise((_, reject) => {
gz.on("error", reject);
out.on("error", reject);
});
try {
for (const entry of walkFiles(srcDir)) {
if (entry.symlink !== undefined) {
if (entry.symlink.length > 100) {
throw new Error(`symlink target too long for ustar: ${entry.rel} -> ${entry.symlink}`);
}
await writeWithBackpressure(gz, entryHeader(entry.rel, 0, "2", entry.symlink));
continue;
}
const st = fs.statSync(entry.abs);
const size = st.size;
const mode = st.mode & 0o111 ? 0o755 : 0o644;
await writeWithBackpressure(gz, entryHeader(entry.rel, size, "0", undefined, mode));
if (size > 0) await Promise.race([pipeFileInto(gz, failure, entry.abs), failure]);
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
if (pad > 0) await writeWithBackpressure(gz, Buffer.alloc(pad, 0));
}
await writeWithBackpressure(gz, Buffer.alloc(BLOCK * 2, 0)); // terminator
await Promise.race([
new Promise((resolve, reject) => {
out.on("finish", resolve);
out.on("error", reject);
gz.end();
}),
failure,
]);
} catch (err) {
gz.destroy();
out.destroy();
throw err;
}
}
// ─── extraction ──────────────────────────────────────────────────────────────────
/**
* Promise-based byte source over a gunzip stream. `read(n)` waits until `n`
* bytes are buffered (or EOF); `readSome()` returns whatever is available, for
* streaming large payloads into files without whole-file buffering.
*/
class BlockSource {
constructor(stream) {
this.buffer = Buffer.alloc(0);
this.error = null;
this.ended = false;
this.waiter = null;
stream.on("data", (chunk) => {
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
this.notify();
});
stream.on("end", () => {
this.ended = true;
this.notify();
});
stream.on("error", (err) => {
this.error = err;
this.notify();
});
}
notify() {
if (this.waiter) {
const waiter = this.waiter;
this.waiter = null;
waiter();
}
}
readSome() {
return new Promise((resolve, reject) => {
const attempt = () => {
if (this.error) return reject(this.error);
if (this.buffer.length > 0) {
const out = this.buffer;
this.buffer = Buffer.alloc(0);
return resolve(out);
}
if (this.ended) return resolve(null);
this.waiter = attempt;
};
attempt();
});
}
unshift(buf) {
if (buf && buf.length > 0) this.buffer = Buffer.concat([buf, this.buffer]);
}
async read(n) {
let acc = null;
let remaining = n;
while (remaining > 0) {
const chunk = await this.readSome();
if (chunk === null) return null; // EOF before n bytes
if (chunk.length > remaining) {
acc = acc
? Buffer.concat([acc, chunk.subarray(0, remaining)])
: chunk.subarray(0, remaining);
this.unshift(chunk.subarray(remaining));
remaining = 0;
} else {
acc = acc ? Buffer.concat([acc, chunk]) : chunk;
remaining -= chunk.length;
}
}
return acc ?? Buffer.alloc(0);
}
}
function parseOctal(header, offset, length) {
const raw = header.toString("utf8", offset, offset + length).replace(/[\0 ]+$/, "");
return raw.length === 0 ? 0 : Number.parseInt(raw, 8);
}
function cstring(header, offset, length) {
const raw = header.toString("utf8", offset, offset + length);
const nul = raw.indexOf("\0");
return nul === -1 ? raw : raw.slice(0, nul);
}
function checksumMatches(header) {
const stored = parseOctal(header, 148, 8);
const probe = Buffer.from(header);
probe.fill(" ", 148, 156); // checksum field counts as spaces while summing
let sum = 0;
for (const byte of probe) sum += byte;
return sum === stored;
}
/** Stream exactly `size` bytes from the reader into `outStream`. */
async function copyN(reader, size, outStream) {
let remaining = size;
while (remaining > 0) {
const chunk = await reader.readSome();
if (chunk === null) {
throw new Error(`unexpected EOF after ${size - remaining} of ${size} bytes`);
}
const take = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
if (chunk.length > remaining) reader.unshift(chunk.subarray(remaining));
remaining -= take.length;
if (!outStream.write(take)) await once(outStream, "drain");
}
}
/**
* Extract a tarball written by `createTarGz` (ustar + GNU LongLink) into
* `destDir`. Returns the number of entries written.
*/
export async function extractTarGz(archiveFile, destDir) {
fs.mkdirSync(destDir, { recursive: true });
const src = createReadStream(archiveFile);
const gunzip = createGunzip();
src.pipe(gunzip);
const reader = new BlockSource(gunzip);
const zeros = Buffer.alloc(BLOCK);
let longName = null;
let longLink = null;
let entries = 0;
for (;;) {
const header = await reader.read(BLOCK);
if (header === null) break; // tolerate archives missing the final zero blocks
if (header.equals(zeros)) {
const second = await reader.read(BLOCK);
if (second !== null && !second.equals(zeros)) {
throw new Error("corrupt archive: data after terminator block");
}
break;
}
if (!checksumMatches(header)) {
throw new Error(`tar header checksum mismatch at entry #${entries + 1}`);
}
let name = cstring(header, 0, 100);
const size = parseOctal(header, 124, 12);
const typeflag = String.fromCharCode(header[156] || 0x30);
let linkname = cstring(header, 157, 100);
const prefix = cstring(header, 345, 155);
if (prefix) name = `${prefix}/${name}`;
if (longName !== null) {
name = longName;
longName = null;
}
if (longLink !== null) {
linkname = longLink;
longLink = null;
}
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
if (typeflag === "L" || typeflag === "K") {
const payload = await reader.read(size);
if (payload === null) throw new Error("unexpected EOF in LongLink payload");
const value = cstring(payload, 0, payload.length);
if (typeflag === "L") longName = value;
else longLink = value;
if (pad > 0) await reader.read(pad);
continue;
}
const target = safeJoin(destDir, name);
if (typeflag === "5") {
fs.mkdirSync(target, { recursive: true });
} else if (typeflag === "2") {
if (linkname.length === 0) throw new Error(`symlink entry ${name} has empty target`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.rmSync(target, { force: true });
fs.symlinkSync(linkname, target);
} else if (typeflag === "1") {
const sourceAbs = safeJoin(destDir, linkname);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(sourceAbs, target);
} else {
// Regular file ("0" or "\0"). The packer never stores directory entries,
// so parent directories are materialized here.
fs.mkdirSync(path.dirname(target), { recursive: true });
const sink = createWriteStream(target, { flags: "w" });
const finished = once(sink, "finish");
sink.on("error", (err) => gunzip.destroy(err));
await copyN(reader, size, sink);
sink.end();
await finished;
const storedMode = parseOctal(header, 100, 8);
if (storedMode) fs.chmodSync(target, storedMode);
}
if (pad > 0) {
const skip = await reader.read(pad);
if (skip === null) throw new Error(`unexpected EOF in padding of ${name}`);
}
entries += 1;
}
src.destroy();
return { entries };
}
function safeJoin(destDir, name) {
const normalized = path.normalize(name).split(path.sep).join("/");
if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
throw new Error(`unsafe tar entry path: ${name}`);
}
return path.join(destDir, ...normalized.split("/"));
}

View File

@@ -1,16 +1,28 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
makeGitAncestryProbe,
readBuildSha,
resolveBuildProvenance,
} from "./buildProvenance.ts";
import {
MCP_CLOSURE_SPOT_CHECK_PATH,
computeMcpClosure,
findLeakedTestArtifactPaths,
findMissingMcpClosurePaths,
} from "./mcpPublishedFilesClosure.ts";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
PACK_ARTIFACT_REQUIRED_PATHS,
findMissingArtifactPaths,
findUnexpectedArtifactPaths,
parseJsonValuesOutput,
} from "./pack-artifact-policy.ts";
const __filename: string = fileURLToPath(import.meta.url);
@@ -24,12 +36,29 @@ function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
return execFileSync(command, commandArgs, {
if (stdio === "inherit") {
execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
maxBuffer: 64 * 1024 * 1024,
});
return "";
}
const result = spawnSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
(result.stderr || result.stdout || `npm exited with status ${result.status}`).trim()
);
}
return `${result.stdout || ""}\n${result.stderr || ""}`;
}
function ensureAppStagingReady(): void {
@@ -43,15 +72,39 @@ function ensureAppStagingReady(): void {
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
type PackReport = {
files: Array<{ path: string }>;
filename?: string;
entryCount?: number;
size?: number;
unpackedSize?: number;
};
function findPackReport(value: unknown): PackReport | null {
if (Array.isArray(value)) {
for (const item of value) {
const report = findPackReport(item);
if (report) return report;
}
return null;
}
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
if (Array.isArray(record.files)) return record as unknown as PackReport;
for (const child of Object.values(record)) {
const report = findPackReport(child);
if (report) return report;
}
return null;
}
function runPackDryRun(): PackReport {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
const jsonPayload =
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
const parsed = JSON.parse(jsonPayload);
const packReport = Array.isArray(parsed) ? parsed[0] : null;
const packReport = parseJsonValuesOutput(output)
.map(findPackReport)
.find((report): report is PackReport => report !== null);
if (!packReport || !Array.isArray(packReport.files)) {
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
@@ -78,17 +131,17 @@ function formatBytes(bytes: number): string {
}
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
// required-runtime-files check (which needs the built dist/). Source-side policy checks
// still run against the real `npm pack --dry-run` file list: unexpected files (e.g. stray
// bin/*.sh), test/spec leaks, and missing MCP closure files. This catches source regressions
// cheaply on the fast-path (PR→release), instead of only on the release PR's full Package
// Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");
try {
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const artifactPaths: string[] = packReport.files.map((file) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
@@ -97,11 +150,20 @@ try {
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
// #3821 — broad `files` prefixes (open-sse/, src/lib/, ...) would otherwise allow
// co-located *.test.* / __tests__ leaks; ban them explicitly on the real pack list.
const leakedTestPaths: string[] = findLeakedTestArtifactPaths(artifactPaths);
// #3578 — MCP runs from published TypeScript source; every reachable file must pack.
const mcpClosure: string[] = computeMcpClosure(ROOT);
const missingMcpPaths: string[] = findMissingMcpClosurePaths(artifactPaths, mcpClosure);
console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
console.log(` Entry count: ${packReport.entryCount}`);
console.log(` Packed size: ${formatBytes(packReport.size)}`);
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
console.log(` MCP closure: ${mcpClosure.length} source files checked`);
if (unexpectedPaths.length > 0) {
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
@@ -117,10 +179,56 @@ try {
}
}
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
if (leakedTestPaths.length > 0) {
console.error(
"\n❌ Test/spec files leaked into the npm publish artifact (tighten package.json files negations):"
);
for (const leakedPath of leakedTestPaths) {
console.error(` - ${leakedPath}`);
}
}
if (missingMcpPaths.length > 0) {
console.error(
"\n❌ MCP-reachable source files are missing from the npm publish artifact (would 404 --mcp):"
);
for (const missingPath of missingMcpPaths) {
console.error(` - ${missingPath}`);
}
if (missingMcpPaths.includes(MCP_CLOSURE_SPOT_CHECK_PATH)) {
console.error(` (includes the #3578 bug file ${MCP_CLOSURE_SPOT_CHECK_PATH})`);
}
}
if (
unexpectedPaths.length > 0 ||
missingRequiredPaths.length > 0 ||
leakedTestPaths.length > 0 ||
missingMcpPaths.length > 0
) {
process.exit(1);
}
// #10427: an artifact is only shippable if it can be traced to the release line. The
// 2026-08-14 gateway outage was a package built from a feature branch that predated the
// fix it was supposed to carry — nothing in this gate noticed. Skipped under
// --policy-only, which deliberately runs without a build (no dist/BUILD_SHA to check).
if (!POLICY_ONLY) {
const provenance = resolveBuildProvenance({
buildSha: readBuildSha(process.cwd()),
isAncestorOfRelease: makeGitAncestryProbe(
process.env.OMNIROUTE_RELEASE_REF || "origin/main",
process.cwd()
),
allowOverride: process.env.OMNIROUTE_ALLOW_CANARY_BUILD === "1",
});
console.log(`\n[provenance] ${provenance.message}`);
if (!provenance.ok) {
console.error("\n❌ Build provenance check failed.");
process.exit(1);
}
}
console.log("\n✅ Pack artifact policy check passed.");
} catch (error) {
console.error(`\n❌ Pack artifact validation failed: ${error.message}`);

View File

@@ -42,6 +42,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
"backupRetention", // db-internal: importado só por db/backup.ts e db/migrationRunner.ts (política de retenção compartilhada; mora fora de backup.ts porque core.ts importa migrationRunner.ts — importar backup.ts de lá fecharia um ciclo, #10421)
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
@@ -49,6 +50,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
"connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
@@ -63,6 +65,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"probeUtils", // db-internal: importado so por db/core.ts (retryProbeIfTransient no caminho da corruption-probe, #9541); testado por tests/unit/probe-9541-repro.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798)
@@ -82,16 +85,19 @@ export const INTENTIONALLY_INTERNAL = new Set([
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o
// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais.
// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
// são leituras read-only de um arquivo externo, com caminho/escopo próprios.
// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
// Esta rota NÃO consulta o DB do OmniRoute (getDbInstance) — ela abre o
// SQLite de OUTRO aplicativo (Kiro) para auto-importar credenciais.
// Por isso NÃO pode viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
// é uma leitura read-only de um arquivo externo, com caminho/escopo próprio.
// Continua no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers.
// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para
// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções.
// O análogo do Cursor (src/app/api/oauth/cursor/auto-import/route.ts) NÃO
// precisa de entrada aqui: o SQL contra o state.vscdb externo do Cursor vive
// em src/lib/cursor/tokenExtractor.ts, fora do escopo desta checagem (que só
// varre src/app/api/**/route.ts e open-sse/handlers/*.ts).
const EXTERNAL_DB_ALLOWED = new Set([
"src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
]);

View File

@@ -36,7 +36,6 @@ const DOCS_ROOT = path.join(REPO_ROOT, "docs");
const EXCLUDE_PREFIXES = [
path.join(DOCS_ROOT, "i18n") + path.sep,
path.join(DOCS_ROOT, "screenshots") + path.sep,
path.join(DOCS_ROOT, "superpowers") + path.sep,
path.join(DOCS_ROOT, "diagrams", "exported") + path.sep,
];

View File

@@ -3,7 +3,7 @@
//
// Two tiers of checks:
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
// that historically caused the worst drift across README / AGENTS / docs.
// that historically caused the worst drift across user-facing documentation.
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
// which is auto-generated from src/shared/constants/providers.ts)
// - i18n locale count (source of truth: config/i18n.json `locales`)
@@ -18,9 +18,13 @@
// Exits 0 on success, 1 on STRICT drift (or any drift with --strict).
// Run: node scripts/check/check-docs-counts-sync.mjs
//
// NOTE: the provider check trusts PROVIDER_REFERENCE.md as the canonical total. If a
// provider is added to the code but the reference is not regenerated, this guard will
// not catch it — regenerate with `npm run gen:provider-reference` before relying on it.
// NOTE: PROVIDER_REFERENCE.md is no longer blindly trusted — a STRICT check compares
// the doc's `Total providers` against the live provider modules (the same collections
// the generator reads), so a hand-stale doc is a red, not a silently propagated total.
// Fix by running `npm run gen:provider-reference`. Additional STRICT coverage added in
// the 2026-08-12 hardening: llm.txt + package.json description (providers), migration
// count (README/AGENTS/llm.txt), and canonical numbers inside the README SVG diagrams
// (providers / MCP tools / routing strategies / free-tier pools).
import fs from "node:fs";
import { spawnSync } from "node:child_process";
@@ -76,6 +80,13 @@ export function readProviderTotal() {
return parseProviderTotal(fs.readFileSync(abs, "utf8"));
}
// STRICT: number of SQL migration files shipped with the app.
export function countMigrations() {
const abs = path.join(ROOT, "src", "lib", "db", "migrations");
if (!fs.existsSync(abs)) return 0;
return fs.readdirSync(abs).filter((f) => f.endsWith(".sql")).length;
}
// STRICT: canonical i18n locale count, read from the shared config.
export function countLocales() {
const abs = path.join(ROOT, "config", "i18n.json");
@@ -147,18 +158,33 @@ function readCodeFacts() {
'import {pluginTools} from "./open-sse/mcp-server/tools/pluginTools.ts";',
'import {notionTools} from "./open-sse/mcp-server/tools/notionTools.ts";',
'import {obsidianTools} from "./open-sse/mcp-server/tools/obsidianTools.ts";',
'import {localCorpusTools} from "./open-sse/mcp-server/tools/localCorpusTools.ts";',
'import {compressionTools} from "./open-sse/mcp-server/tools/compressionTools.ts";',
// Live provider total — the SAME collections gen-provider-reference.ts unions, so the
// doc-vs-live check below cannot drift from the generator's definition of "provider".
'import * as PROV from "./src/shared/constants/providers.ts";',
"const provCols=[PROV.FREE_PROVIDERS,PROV.NOAUTH_PROVIDERS,PROV.OAUTH_PROVIDERS,",
"PROV.WEB_COOKIE_PROVIDERS,PROV.APIKEY_PROVIDERS,PROV.LOCAL_PROVIDERS,PROV.SEARCH_PROVIDERS,",
"PROV.AUDIO_ONLY_PROVIDERS,PROV.UPSTREAM_PROXY_PROVIDERS,PROV.CLOUD_AGENT_PROVIDERS,",
"PROV.SYSTEM_PROVIDERS];",
"const pids=new Set();",
"for(const c of provCols)for(const p of Object.values(c||{}))if(p&&p.id)pids.add(p.id);",
"const cols={MCP_TOOLS,memoryTools,skillTools,agentSkillTools,githubSkillTools,poolTools,",
"gamificationTools,pluginTools,notionTools,obsidianTools,compressionTools};",
"gamificationTools,pluginTools,notionTools,obsidianTools,localCorpusTools,compressionTools};",
"const sc=new Set();",
"for(const col of Object.values(cols))for(const t of Object.values(col))",
"for(const x of (t?.scopes||[]))sc.add(x);",
"const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);",
"const by=(c)=>cli.filter(x=>x.category===c).length;",
// "Free forever" = every provider whose free access renews or needs no key at all.
// one-time-initial (signup credits) and discontinued pools are excluded on purpose.
"const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',",
"'recurring-credit','keyless']);",
"const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);",
'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,',
"freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,",
"cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),",
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size}));",
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size}));",
].join("");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "docs-counts-"));
try {
@@ -252,6 +278,82 @@ export function makeNumberClaimValidator(expected, opts) {
};
}
// --- v3.8.50 hardening validators --------------------------------------------
// PURE: doc total must equal the live provider-module total (closes the falso-verde
// found in the 2026-08-12 audit: the doc sat hand-stale at 291 while the modules
// defined 338, and every downstream check inherited the stale total).
export function makeProviderReferenceValidator(expected) {
return (content) => {
const total = parseProviderTotal(content);
if (!total) return { ok: false, detail: "no `Total providers: **N**` marker found" };
if (total === expected)
return { ok: true, detail: `doc total ${total} matches the live provider modules` };
return {
ok: false,
detail:
`doc total ${total} is stale — the live provider modules define ${expected} ` +
`(run npm run gen:provider-reference)`,
};
};
}
// PURE: the npm package description must carry the live provider count.
export function makePackageDescriptionValidator(expected) {
return (content) => {
let desc = "";
try {
desc = String(JSON.parse(content).description || "");
} catch {
return { ok: false, detail: "package.json could not be parsed" };
}
if (desc.includes(String(expected)))
return { ok: true, detail: `description mentions the live provider count ${expected}` };
return {
ok: false,
detail: `description does not mention the live provider count ${expected}: "${desc}"`,
};
};
}
// PURE: sweep an SVG's text/aria content for the canonical numbers. Patterns are
// deliberately narrow — they anchor on the surrounding words so path coordinates,
// width/font-size attributes and small unrelated counts ("15 providers ToS-flagged",
// "100+ providers") can never register as claims. Providers require 3+ digits for the
// same reason.
const SVG_CANONICAL_PATTERNS = [
{ key: "providers", what: "providers", pattern: /(\d{3,4}) (?:AI )?providers\b/g },
{ key: "mcpTools", what: "MCP tools", pattern: /MCP (?:server with |with |\()(\d+)/g },
{ key: "strategies", what: "routing strategies", pattern: /(\d+) routing strategies\b/g },
{ key: "pools", what: "free-tier pools", pattern: /(\d+) provider pools\b/g },
];
export function checkSvgCanonicalNumbers(content, expected) {
const stale = [];
let claims = 0;
for (const { key, what, pattern } of SVG_CANONICAL_PATTERNS) {
if (expected[key] == null) continue;
for (const m of content.matchAll(pattern)) {
claims++;
const value = Number(m[1]);
if (value !== expected[key]) stale.push(`"${m[0]}" (${what} — code has ${expected[key]})`);
}
}
if (!claims) return { ok: true, detail: "no canonical-number claims in this SVG" };
if (!stale.length) return { ok: true, detail: `${claims} canonical claim(s) match the code` };
return { ok: false, detail: `stale: ${[...new Set(stale)].join(", ")}` };
}
// The README-embedded diagrams that historically rotted because no gate read them
// (the alt-text in README.md is checked, the SVG text nodes never were).
const SVG_DIAGRAM_FILES = [
"docs/diagrams/readme-hero.svg",
"docs/diagrams/free-tier-budget.svg",
"docs/diagrams/promise-pillars.svg",
"docs/diagrams/comparison-table.svg",
"docs/diagrams/cli-terminal.svg",
"docs/diagrams/tier-cascade.svg",
];
export function buildChecks() {
return [
{
@@ -259,14 +361,33 @@ export function buildChecks() {
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
files: ["README.md", "AGENTS.md", "llm.txt"],
},
{
label: "Provider count (package.json description)",
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["package.json"],
validate: makePackageDescriptionValidator(readProviderTotal()),
},
{
label: "DB migrations count",
actual: countMigrations(),
docKey: "migrations",
strict: true,
files: ["README.md", "AGENTS.md", "llm.txt"],
validate: makeNumberClaimValidator(countMigrations(), {
what: "migrations",
pattern: /(\d+)\+? migrations?\b/gi,
}),
},
{
label: "i18n locales count",
actual: countLocales(),
docKey: "i18n locales",
strict: true,
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
files: ["docs/README.md", "docs/guides/I18N.md"],
},
...(() => {
const f = readCodeFacts();
@@ -289,6 +410,30 @@ export function buildChecks() {
validate: makeNumberClaimValidator(expected, { what, ...opts }),
});
return [
{
label: "Provider reference total (doc vs live modules)",
actual: f.providers,
docKey: "providers (live)",
strict: true,
files: ["docs/reference/PROVIDER_REFERENCE.md"],
validate: makeProviderReferenceValidator(f.providers),
},
{
label: "SVG canonical numbers (live code)",
actual:
`${f.providers} providers / ${f.mcpTools} MCP tools / ` +
`${countRoutingStrategies()} strategies / ${f.freePools} pools`,
docKey: "SVG canonical numbers",
strict: true,
files: SVG_DIAGRAM_FILES,
validate: (content) =>
checkSvgCanonicalNumbers(content, {
providers: f.providers,
mcpTools: f.mcpTools,
strategies: countRoutingStrategies(),
pools: f.freePools,
}),
},
{
label: "Free-tier headline (live catalog)",
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools`,
@@ -313,23 +458,18 @@ export function buildChecks() {
// total ("33 tools (25 CLI Code's …)") are not the MCP aggregate
// per-module rows read "… tool definitions (N tools" / "… management tools
// (N tools" — the word tool(s)/definitions sits right before the paren. The
// aggregate ("MCP Server (104 tools", "all 104 tools") never does.
// aggregate ("MCP Server (109 tools", "all 109 tools") never does.
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
skipAfter: /^\s*\(\d+ CLI/,
},
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
["README.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
),
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, [
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "AGENTS.md"]),
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
claim(f.freeForever, "free-forever providers", { pattern: /(\d+) free forever/gi }, [
"README.md",
"CLAUDE.md",
"AGENTS.md",
"docs/diagrams/promise-pillars.svg",
]),
claim(
f.cliTotal,
"CLI tools",
{ pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi },
["README.md"]
),
];
})(),
{

View File

@@ -61,6 +61,10 @@ const IGNORE_FROM_CODE = new Set([
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
// systemd-injected notify socket path (sd_notify protocol, see
// scripts/dev/systemd-notify.mjs) — set by systemd only when running under
// a unit, never user config.
"NOTIFY_SOCKET",
// XDG Base Directory cache root — read (never defined by OmniRoute) so the
// Android/Termux serve path can honor an operator-set cache location (#8519).
"XDG_CACHE_HOME",
@@ -91,9 +95,27 @@ const IGNORE_FROM_CODE = new Set([
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// Set by the Actions runner; the ts7 ratchet appends its job summary there
// (scripts/check/check-ts7-diagnostics-ratchet.mjs) — never OmniRoute runtime config (#9985).
"GITHUB_STEP_SUMMARY",
// Same class as BASE_REF: CI passes the PR base ref to the ts7 diagnostics ratchet
// (scripts/check/check-ts7-diagnostics-ratchet.mjs) — a check signal, not runtime config (#9985).
"TS7_BASE_REF",
// CI passes BASE_REF=${{ github.base_ref }} to the OpenAPI breaking-change gate
// (scripts/check/check-openapi-breaking.mjs) — a build/check signal, not OmniRoute runtime config.
"BASE_REF",
// Same class as BASE_REF above: the `changes` job passes these four to the
// self-targeting-PR guard (scripts/check/check-pr-self-target.mjs) so it can compare a PR's
// head against its base. CI-only signals from github.head_ref / github.base_ref /
// pull_request.{head,base}.sha — never OmniRoute runtime config, and meaningless in a .env.
"HEAD_REF",
"HEAD_SHA",
"BASE_SHA",
// Escape hatch for the test-masking gate's release-scale skip
// (scripts/check/check-test-masking.mjs): above ~300 changed test files the per-file diff
// subchecks are skipped, and this raises that cap for anyone who wants the full pass anyway.
// A gate tuning knob, not application configuration.
"TEST_MASKING_MAX_CHANGED_TESTS",
// PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body);
// a CI-only signal, never an OmniRoute runtime config (Phase 7.10).
"PR_BODY",
@@ -104,6 +126,10 @@ const IGNORE_FROM_CODE = new Set([
// ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151).
"COMBO_LIVE_BASE_URL",
"COMBO_LIVE_API_KEY",
// Ad-hoc mesh/coverage scripts under scripts/ad-hoc/*.mjs (mesh-send, mesh-run,
// verify-coverage). Operator-supplied script secrets, not OmniRoute runtime config.
"BOT_TOKEN",
"BOT_URL",
// Homologation E2E suite (npm run homolog) vars — configured via the dedicated
// .env.homolog file (template: .env.homolog.example), never in the runtime .env.
// Test/ops-only signals against the homologation VPS, same class as COMBO_LIVE_*.
@@ -130,8 +156,11 @@ const IGNORE_FROM_CODE = new Set([
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
// Build-time overrides for generate-api-commands.mjs (spec input / commands output dir).
// OPENAPI_OUT_DIR exists so tests/unit/cli-api-generator-ref-params.test.ts can regenerate
// into a scratch dir instead of the real bin/cli/api-commands/ tree.
"OPENAPI_SPEC",
"OPENAPI_OUT_DIR",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",
@@ -179,6 +208,10 @@ const IGNORE_FROM_CODE = new Set([
// NVIDIA diagnostic/test helpers used only by ad-hoc scripts.
"NVIDIA_BASE_URL",
"NVIDIA_MODEL",
// Discord integration ad-hoc script (scripts/ad-hoc/mesh-send.mjs) —
// operator-supplied bot credentials, not user-facing OmniRoute config.
"BOT_TOKEN",
"BOT_URL",
// XDG standard data directory — set by OS/desktop session, not OmniRoute config.
// Read by setup-open-code.mjs to locate platform-specific OpenCode data dir.
"XDG_DATA_HOME",

View File

@@ -107,6 +107,12 @@ const ENV_VAR_ALLOWLIST = new Set([
"NINEROUTER_API_KEY", // injected into the 9router subprocess at spawn (EMBEDDED-SERVICES.md)
"CLAUDE_CODE_MAX_OUTPUT_TOKENS", // Claude Code CLI's own env var (CODEX-CLI-CONFIGURATION.md)
"CODEX_HOME", // Codex CLI's own config-home env var (CODEX-CLI-CONFIGURATION.md)
// Gemini CLI's own auth-routing env vars. `omniroute run gemini` DELETES them
// from the spawned child's env (bin/cli/commands/run.mjs) so a stored Vertex /
// Code Assist session cannot override the OmniRoute-directed launch — a delete
// on a copied env object, never a `process.env.X` read. (CLI-INTEGRATIONS.md)
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_GENAI_USE_GCA",
"OPENAI_API_BASE", // legacy OpenAI base-URL env var some downstream tools (e.g. Aider) read (CLI-INTEGRATIONS.md)
"PROMPTFOO_PROVIDER_KEY", // promptfoo's own provider-key env var, used by the red-team suite (GUARDRAILS.md)
"REDIS_PORT", // docker-compose host-port override (DOCKER_GUIDE.md)
@@ -114,6 +120,15 @@ const ENV_VAR_ALLOWLIST = new Set([
"LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md)
"BRANCH_LOCK_TOKEN", // release branch-protection ops token (QUALITY_GATE_PLAYBOOK.md)
"NEXT_LOCALE", // next-intl locale cookie name (I18N.md)
// Feature flags are resolved by key at runtime — `resolveFeatureFlag()` reads
// `process.env[key]` (src/shared/utils/featureFlags.ts), never a literal
// `process.env.MODELS_CATALOG_PREFIX_MODE`, so this scan cannot see the read.
// The flag is real: defined in featureFlagDefinitions.ts, overridable from the
// dashboard or the environment. (API_REFERENCE.md, VSCODE-COPILOT.md)
"MODELS_CATALOG_PREFIX_MODE",
// Telegram Mini App integration (proposal TELEGRAM-MINIAPP.md, not yet implemented): env vars named in the feasibility analysis but no code reads them yet.
"TELEGRAM_WEBHOOK_URL", // proposal-only: Telegram webhook public endpoint (TELEGRAM-MINIAPP.md, future feature)
"TELEGRAM_WEBHOOK_SECRET", // proposal-only: Telegram webhook HMAC secret (TELEGRAM-MINIAPP.md, future feature)
]);
// Common pluralized / column-header all-caps that aren't env vars
@@ -311,6 +326,7 @@ const ENV_VAR_DENYLIST = new Set([
"AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md)
"MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md)
"ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md)
"SQLITE_FULL", // SQLite result code returned when the disk is full (DATABASE_GUIDE.md)
// ── Code-symbol / naming-convention examples documented in prose ─────────────
"UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md)
"DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md)
@@ -360,19 +376,6 @@ const SKIP_DOC_FILES = new Set([
"docs/reference/PROVIDER_REFERENCE.md", // auto-generated from providers.ts
"docs/openapi.yaml",
"docs/i18n", // translations — separate workflow
// Design / research / plan docs: by definition describe not-yet-built files and
// proposed (not-yet-shipped) endpoints (each carries a `Status: Design`/`Active
// research`/`Plano` header). Same rationale as the audit report above — these are
// forward-looking specs, not living API docs, so their forward references are
// expected, not fabrications.
"docs/research", // DISCOVERY_TOOL_DESIGN.md, UNLIMITED_LLM_ACCESS.md, …
"docs/superpowers/plans", // dated implementation plans (files described before they exist)
"docs/superpowers/specs", // dated research/spec reports (point-in-time findings, may cite proposed/not-yet-built endpoints, env vars, and files) — same rationale as the plans/research dirs above
// Release notes are historical, point-in-time records: they intentionally describe
// modules/paths as they were at that release (e.g. a module later moved or renamed).
// Rewriting them to today's layout would falsify history — out of scope for a
// living-docs accuracy gate.
"docs/releases",
// Forward-looking coverage plan: a `- [ ]` checklist of test targets and helper
// components to be created. Same rationale as the design/plan docs above.
"docs/ops/COVERAGE_PLAN.md",

View File

@@ -11,6 +11,7 @@
// igual ao próprio teto ficava presa no baseline para sempre — ver #8584.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
@@ -22,6 +23,7 @@ const BASELINE_PATH = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const BASE_REF = getArg("--base-ref"); // SHA for PR base-relative mode (#8522)
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
@@ -37,20 +39,39 @@ const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "
* (loc < frozen), entao uma entrada igual ao proprio teto nunca saia da lista,
* por mais abaixo do cap que estivesse (3 casos reais no v3.8.49).
*
* Quando `baseLocByFile` e fornecido (modo PR), a violacao e computada contra
* o MAIOR entre o valor congelado e o valor na base -- assim um PR inocente
* (head === base no arquivo) nao e penalizado por drift herdado (#8522).
*
* @param {Object} currentLocByFile — LOC atuais (head)
* @param {Object} frozen — baseline congelado
* @param {number} cap — teto para arquivos novos
* @param {Object} [baseLocByFile] — LOC na branch base (opcional, modo PR)
* @returns {{violations: string[], improvements: [string, number][], redundant: string[]}}
*/
export function evaluateFileSizes(currentLocByFile, frozen, cap) {
export function evaluateFileSizes(currentLocByFile, frozen, cap, baseLocByFile) {
const violations = [];
const improvements = [];
const redundant = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
if (loc > frozen[file])
const threshold = baseLocByFile
? Math.max(frozen[file], baseLocByFile[file] ?? frozen[file])
: frozen[file];
if (loc > threshold)
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
else if (loc <= cap) redundant.push(file);
} else if (loc > cap) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
if (!baseLocByFile) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
} else {
// Modo PR: so viola se cresceu alem do que ja estava na base
const baseLoc = baseLocByFile[file] ?? 0;
const prThreshold = Math.max(cap, baseLoc);
if (loc > prThreshold)
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
}
}
}
return { violations, improvements, redundant };
@@ -108,6 +129,30 @@ function collectTestLoc() {
return out;
}
/**
* Computa LOC por arquivo a partir de um ref git (branch, SHA, tag).
* Usado pelo modo --base-ref para obter a contagem na base do PR (#8522).
* @param {string} ref — git ref (e.g. SHA da branch base)
* @param {string[]} files — lista de paths relativos ao ROOT
* @returns {Object} mapa file → line count
*/
function getBaseLoc(ref, files) {
const out = {};
for (const file of files) {
try {
const buf = execFileSync("git", ["show", `${ref}:${file}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
out[file] = buf.split("\n").length;
} catch {
// Arquivo nao existe na base (novo no PR) — tratado como 0
}
}
return out;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
@@ -117,7 +162,17 @@ function main() {
const cap = baseline.cap;
const frozen = baseline.frozen || {};
const current = collectLoc();
const { violations, improvements, redundant } = evaluateFileSizes(current, frozen, cap);
// Modo PR: computa LOC na branch base para comparacao relativa (#8522)
const baseLoc = BASE_REF ? getBaseLoc(BASE_REF, Object.keys(current)) : undefined;
if (BASE_REF) {
const baseKeys = Object.keys(baseLoc).length;
console.log(
`[file-size] modo PR (--base-ref ${BASE_REF.slice(0, 12)}): ${baseKeys} arquivos da base computados`
);
}
const { violations, improvements, redundant } = evaluateFileSizes(current, frozen, cap, baseLoc);
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
@@ -129,7 +184,7 @@ function main() {
improvements: testImprovements,
redundant: testRedundant,
} = typeof testCap === "number"
? evaluateFileSizes(currentTests, testFrozen, testCap)
? evaluateFileSizes(currentTests, testFrozen, testCap, BASE_REF ? baseLoc : undefined)
: { violations: [], improvements: [], redundant: [] };
if (UPDATE) {

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "tinyglobby";
import { resolveImport } from "../quality/build-test-impact-map.mjs";
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"];
const SOURCE_GLOBS = [
"src/**/*.{ts,tsx,mts,js,mjs}",
"open-sse/**/*.{ts,tsx,mts,js,mjs}",
"bin/**/*.{ts,tsx,mts,js,mjs}",
];
const IGNORE = [
"**/__tests__/**",
"**/*.test.*",
"**/*.spec.*",
"**/fixtures/**",
"**/generated/**",
];
const STATIC_IMPORT_RE =
/(?:import|export)[^'"()]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
const TEST_MASK_RE =
/^\+.*(?:\b(?:it|test|describe)\.(?:skip|todo)\b|\b(?:xit|xtest|xdescribe)\s*\()/;
const REFERENCE_RE = /^(?:#\d+|https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+)$/;
function normalize(file) {
return file.split(path.sep).join("/");
}
function isProduction(file) {
return (
SOURCE_ROOTS.some((root) => file.startsWith(root)) &&
!IGNORE.some((pattern) => {
const token = pattern.replaceAll("**/", "").replaceAll("/**", "").replaceAll("*", "");
return token && file.includes(token);
})
);
}
function isBarrel(file, code) {
return /(?:^|\/)index\.[cm]?[jt]sx?$/.test(file) && /\bexport\s+(?:\*|\{)/.test(code);
}
function importEdges(root) {
const edges = [];
const files = globSync(SOURCE_GLOBS, { cwd: root, absolute: true, ignore: IGNORE });
for (const absolute of files) {
const consumer = normalize(path.relative(root, absolute));
const code = fs.readFileSync(absolute, "utf8");
for (const match of code.matchAll(STATIC_IMPORT_RE)) {
const resolved = resolveImport(match[1] || match[2], absolute, root);
if (resolved) {
edges.push({
module: normalize(path.relative(root, resolved)),
consumer,
kind: isBarrel(consumer, code) ? "barrel" : "static",
});
}
}
for (const match of code.matchAll(DYNAMIC_IMPORT_RE)) {
const resolved = resolveImport(match[1], absolute, root);
if (resolved) {
edges.push({
module: normalize(path.relative(root, resolved)),
consumer,
kind: "dynamic-import",
});
}
}
}
return edges.sort((a, b) =>
`${a.module}\0${a.consumer}\0${a.kind}`.localeCompare(`${b.module}\0${b.consumer}\0${b.kind}`)
);
}
export function validateAllowlist(value) {
const entries = Array.isArray(value) ? value : value?.entries;
if (!Array.isArray(entries))
throw new Error("forgotten-sibling allowlist must contain an entries array");
return entries.map((entry, index) => {
for (const field of ["consumer", "candidateTest", "rationale", "reference"]) {
if (typeof entry?.[field] !== "string" || !entry[field].trim()) {
throw new Error(`forgotten-sibling allowlist entry ${index} requires ${field}`);
}
}
if (entry.rationale.trim().length < 20) {
throw new Error(`forgotten-sibling allowlist entry ${index} rationale must be specific`);
}
if (!REFERENCE_RE.test(entry.reference.trim())) {
throw new Error(
`forgotten-sibling allowlist entry ${index} reference must be a GitHub issue or PR`
);
}
return {
consumer: normalize(entry.consumer.trim()),
candidateTest: normalize(entry.candidateTest.trim()),
rationale: entry.rationale.trim(),
reference: entry.reference.trim(),
};
});
}
export function analyzeForgottenSiblingTests({
root = DEFAULT_ROOT,
changedEntries,
impactMap,
allowlist,
changedSymbolsByFile = {},
addedTestLines = [],
}) {
const changed = new Map(changedEntries.map((entry) => [normalize(entry.file), entry.status]));
const changedModules = [...changed.keys()].filter(isProduction).sort();
const maskingAdded = addedTestLines.some((line) => TEST_MASK_RE.test(line));
const allow = new Map(
allowlist.map((entry) => [`${entry.consumer}\0${entry.candidateTest}`, entry])
);
const findings = [];
const diagnostics = [];
const suppressed = [];
const maskingRisks = [];
for (const edge of importEdges(root)) {
if (!changedModules.includes(edge.module)) continue;
const tests = [...new Set(impactMap.sources?.[edge.consumer] || [])].sort();
if (edge.kind !== "static") {
diagnostics.push({
changedModule: edge.module,
consumer: edge.consumer,
kind: edge.kind,
message: `${edge.kind} resolution is advisory and never blocks`,
});
continue;
}
for (const candidateTest of tests) {
const status = changed.get(candidateTest);
const masking = status === "D" || (status && maskingAdded);
if (masking) {
maskingRisks.push({
changedModule: edge.module,
consumer: edge.consumer,
candidateTest,
reason:
status === "D"
? "candidate sibling test was deleted"
: "candidate sibling test adds skip/todo masking",
});
continue;
}
if (status) continue;
const finding = {
changedModule: edge.module,
changedSymbols: [...(changedSymbolsByFile[edge.module] || [])].sort(),
consumer: edge.consumer,
candidateTest,
reason: "candidate sibling test is absent from the PR diff",
};
const exception = allow.get(`${edge.consumer}\0${candidateTest}`);
if (exception) suppressed.push({ ...finding, exception });
else findings.push(finding);
}
}
return { mode: "advisory", findings, diagnostics, suppressed, maskingRisks };
}
function arg(name, fallback = "") {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
function git(root, args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
}
function changedEntries(root, base) {
return git(root, ["diff", "--name-status", "--diff-filter=ACMRD", `${base}...HEAD`])
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [status, ...files] = line.split("\t");
return { status: status[0], file: files.at(-1) };
});
}
function changedSymbols(root, base, entries) {
const result = {};
const declaration =
/^\+\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/;
for (const entry of entries.filter(({ file }) => isProduction(file))) {
const diff = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", entry.file]);
result[entry.file] = [
...new Set(
diff
.split(/\r?\n/)
.map((line) => line.match(declaration)?.[1])
.filter(Boolean)
),
];
}
return result;
}
function markdown(result, base) {
const lines = [
"## Forgotten sibling tests (advisory)",
"",
`Base: \`${base}\``,
`Unallowlisted findings: ${result.findings.length}`,
`Reviewed exceptions: ${result.suppressed.length}`,
`Resolution diagnostics: ${result.diagnostics.length}`,
`Masking/deletion risks (owned by blocking sibling gates): ${result.maskingRisks.length}`,
"",
];
if (result.findings.length) {
lines.push("### Candidate tests absent from this diff", "");
for (const item of result.findings) {
const symbol = item.changedSymbols.length ? ` (${item.changedSymbols.join(", ")})` : "";
lines.push(
`- \`${item.changedModule}\`${symbol} -> \`${item.consumer}\` -> \`${item.candidateTest}\``
);
}
lines.push("", "> Report-only calibration: these findings do not fail the job.", "");
}
for (const [heading, items] of [
["Resolution diagnostics", result.diagnostics],
["Test masking/deletion risks", result.maskingRisks],
]) {
if (!items.length) continue;
lines.push(`### ${heading}`, "");
for (const item of items)
lines.push(
`- \`${item.changedModule}\` -> \`${item.consumer}\`${item.candidateTest ? ` -> \`${item.candidateTest}\`` : ""}: ${item.reason || item.message}`
);
lines.push("");
}
return `${lines.join("\n")}\n`;
}
function main() {
const root = DEFAULT_ROOT;
const base = arg(
"--base",
process.env.GITHUB_BASE_SHA ||
(process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "HEAD~1")
);
const mapPath = arg("--impact-map", path.join(root, "config/quality/test-impact-map.json"));
const allowlistPath = arg(
"--allowlist",
path.join(root, "config/quality/forgotten-sibling-allowlist.json")
);
const summaryPath = arg("--summary-file", "");
const jsonPath = arg("--json-file", "");
const entries = changedEntries(root, base);
const impactMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
const allowlist = validateAllowlist(JSON.parse(fs.readFileSync(allowlistPath, "utf8")));
const addedTestLines = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", "tests/"])
.split(/\r?\n/)
.filter((line) => line.startsWith("+") && !line.startsWith("+++"));
const result = analyzeForgottenSiblingTests({
root,
changedEntries: entries,
impactMap,
allowlist,
changedSymbolsByFile: changedSymbols(root, base, entries),
addedTestLines,
});
const report = markdown(result, base);
process.stdout.write(report);
for (const [target, contents] of [
[summaryPath, report],
[jsonPath, `${JSON.stringify(result, null, 2)}\n`],
]) {
if (!target) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
}
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
try {
main();
} catch (error) {
console.error(
`forgotten-sibling-tests: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
}

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env node
/**
* check-install-upgrade — proves the two install paths a real user takes, BEFORE publishing.
*
* `check:pack-boot` already proves a fresh install boots. It does NOT prove the path that
* actually broke us: installing the new version OVER an existing one, where ~110 SQLite
* migrations run against a populated database. v3.8.48 shipped as a hotfix precisely because
* the published 3.8.47 crashed on boot, and a v3.8.49 manual test on a real 3.8.48 box was
* what first exercised the upgrade path end to end.
*
* Phase A — clean install: fresh prefix + fresh DATA_DIR, install the packed tarball, boot.
* Phase B — upgrade install: fresh prefix + fresh DATA_DIR, install the PREVIOUS published
* version, boot it (creates + migrates the DB), stop, install the
* packed tarball over the SAME prefix, boot against the SAME DATA_DIR.
*
* Schema convergence is the third assertion, and its DIRECTION is what matters:
*
* fresh upgraded ≠ ∅ → FAIL. A table a clean install creates but an upgrade does not
* means every existing user is missing structure the code expects.
* This is the failure mode that only ever bites upgraders.
* upgraded fresh ≠ ∅ → WARN. Residue: a table whose CREATE left the migration set in
* some past cycle but survives in databases that already had it.
* Harmless, but it means the two paths do not converge — allowlist
* it explicitly so a NEW divergence is still visible.
*
* Usage:
* node scripts/check/check-install-upgrade.mjs [--from <version>] [--skip-upgrade]
*
* `--from` pins the previous version (default: the current `latest` dist-tag on npm).
* Requires `npm run build:cli` first — this is a --with-build gate, like check:pack-boot.
*/
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
const BOOT_DEADLINE_MS = 180_000;
const POLL_INTERVAL_MS = 2_000;
const ALLOWLIST_PATH = "config/quality/install-upgrade-allowlist.json";
const log = (msg) => console.log(`[install-upgrade] ${msg}`);
const warn = (msg) => console.log(`[install-upgrade] ⚠️ ${msg}`);
function pickTarball(packJson) {
const filename = JSON.parse(packJson)?.[0]?.filename;
if (!filename) throw new Error("npm pack --json returned no filename");
return filename;
}
/** Free-ish port per phase so a leaked child from a previous run cannot collide. */
function pickPort(offset) {
return 21000 + offset + (process.pid % 500);
}
function loadAllowlist(root) {
const file = path.join(root, ALLOWLIST_PATH);
if (!fs.existsSync(file)) return { residualTables: {} };
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/**
* Pure verdict on schema convergence — exported so the asymmetry can be tested without
* building, packing and booting anything (the same reason check-test-masking exports its
* helpers: reproducing the deterministic part must not cost a full gate run).
*
* The two directions are NOT symmetric:
* onlyFresh → always a failure. Upgraders would be missing structure.
* onlyUpgraded → residue. Fails only when not recorded in the allowlist.
*/
export function evaluateConvergence({ freshTables, upgradedTables, residualAllowlist = {} }) {
const fresh = freshTables instanceof Set ? freshTables : new Set(freshTables ?? []);
const upgraded = upgradedTables instanceof Set ? upgradedTables : new Set(upgradedTables ?? []);
const onlyFresh = [...fresh].filter((t) => !upgraded.has(t)).sort();
const onlyUpgraded = [...upgraded].filter((t) => !fresh.has(t)).sort();
const unknownResidue = onlyUpgraded.filter((t) => !(t in residualAllowlist));
const failures = [];
if (onlyFresh.length) {
failures.push(
`schema divergence — tables a CLEAN install creates but an UPGRADE does not: ${onlyFresh.join(", ")}. ` +
"Every existing user would be missing these; add the migration."
);
}
if (unknownResidue.length) {
failures.push(
`NEW residual table(s) not in ${ALLOWLIST_PATH}: ${unknownResidue.join(", ")}. ` +
"Either drop them in a migration or record them with a justification."
);
}
return { ok: failures.length === 0, failures, onlyFresh, onlyUpgraded, unknownResidue };
}
/** Table names in a SQLite file, excluding sqlite_* internals. */
function readTables(dbPath) {
if (!fs.existsSync(dbPath)) return null;
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all();
return new Set(rows.map((r) => r.name));
} finally {
db.close();
}
}
function findDb(dataDir) {
const candidates = ["storage.sqlite", "omniroute.sqlite", "data.sqlite"];
for (const name of candidates) {
const p = path.join(dataDir, name);
if (fs.existsSync(p)) return p;
}
const found = fs.readdirSync(dataDir).find((f) => f.endsWith(".sqlite"));
return found ? path.join(dataDir, found) : null;
}
/** Boot an installed CLI and poll health. Returns { ok, version, failures, tail }. */
async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
const binPath = path.join(prefix, "bin", "omniroute");
if (!fs.existsSync(binPath)) {
return { ok: false, failures: [`${label}: bin not found at ${binPath}`], tail: [] };
}
const child = spawn(binPath, ["serve", "--port", String(port)], {
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
JWT_SECRET: "install-upgrade-gate-secret-with-sufficient-length",
API_KEY_SECRET: "install-upgrade-gate-api-key-secret-long",
DISABLE_SQLITE_AUTO_BACKUP: "true",
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const tail = [];
const keepTail = (chunk) => {
tail.push(String(chunk));
while (tail.length > 80) tail.shift();
};
child.stdout.on("data", keepTail);
child.stderr.on("data", keepTail);
let childExit = null;
child.on("exit", (code) => {
childExit = code ?? -1;
});
const deadline = Date.now() + BOOT_DEADLINE_MS;
let result = { ok: false, failures: [`${label}: never became healthy`], tail };
while (Date.now() < deadline) {
if (childExit !== null) {
result = { ok: false, failures: [`${label}: exited with code ${childExit} before serving`], tail };
break;
}
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
const body = await res.json().catch(() => null);
if (res.status === 200 && body && typeof body === "object") {
const failures = [];
// `status` may legitimately report degraded (no providers configured) — the gate
// targets boot crashes and version mismatches, not health of a bare install.
if (expectVersion && body.version !== expectVersion) {
failures.push(`${label}: health reports version ${body.version}, expected ${expectVersion}`);
}
result = { ok: failures.length === 0, version: body.version, failures, tail };
break;
}
} catch {
// not listening yet
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
try {
if (childExit === null) process.kill(-child.pid, "SIGTERM");
} catch {
/* already gone */
}
// Give the process a moment to flush and release the SQLite handle before we read the file.
await new Promise((r) => setTimeout(r, 3_000));
return result;
}
function npmInstallInto(prefix, spec) {
execFileSync("npm", ["install", "-g", "--prefix", prefix, "--no-audit", "--no-fund", spec], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
}
function resolvePreviousVersion(current, explicit) {
if (explicit) return explicit;
const out = execFileSync("npm", ["view", "omniroute", "dist-tags.latest"], { encoding: "utf8" });
const latest = out.trim();
if (!latest) throw new Error("could not resolve omniroute@latest from npm");
if (latest === current) {
// The version under test is already published (re-run of a shipped release): step back
// to the highest published version strictly below it.
const all = JSON.parse(execFileSync("npm", ["view", "omniroute", "versions", "--json"], { encoding: "utf8" }));
const stable = all.filter((v) => !/-(rc|alpha|beta|pre|next)/.test(v) && v !== current);
return stable[stable.length - 1];
}
return latest;
}
async function main() {
const ROOT = process.cwd();
const args = process.argv.slice(2);
const fromIdx = args.indexOf("--from");
const explicitFrom = fromIdx >= 0 ? args[fromIdx + 1] : null;
const skipUpgrade = args.includes("--skip-upgrade");
if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) {
console.error("[install-upgrade] dist/server.js missing — run `npm run build:cli` first");
process.exit(2);
}
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const allowlist = loadAllowlist(ROOT);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-install-upgrade-"));
const failures = [];
const warnings = [];
try {
log(`packing v${version}`);
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
const tarball = path.join(tmp, pickTarball(packOut));
// ---- Phase A: clean install -------------------------------------------------
log("PHASE A — clean install of the packed tarball");
const aPrefix = path.join(tmp, "a-prefix");
const aData = path.join(tmp, "a-data");
fs.mkdirSync(aData, { recursive: true });
npmInstallInto(aPrefix, tarball);
const a = await bootAndProbe({
prefix: aPrefix,
dataDir: aData,
port: pickPort(0),
expectVersion: version,
label: "clean",
});
failures.push(...a.failures);
if (a.ok) log(`clean install healthy on v${a.version}`);
const aDb = findDb(aData);
const freshTables = aDb ? readTables(aDb) : null;
if (!freshTables) failures.push("clean: no SQLite database was created");
else log(`clean install schema: ${freshTables.size} tables`);
// ---- Phase B: upgrade over the previous published version -------------------
let upgradedTables = null;
if (skipUpgrade) {
warn("PHASE B skipped (--skip-upgrade)");
} else {
const previous = resolvePreviousVersion(version, explicitFrom);
log(`PHASE B — upgrade path: omniroute@${previous} → v${version}`);
const bPrefix = path.join(tmp, "b-prefix");
const bData = path.join(tmp, "b-data");
fs.mkdirSync(bData, { recursive: true });
npmInstallInto(bPrefix, `omniroute@${previous}`);
const before = await bootAndProbe({
prefix: bPrefix,
dataDir: bData,
port: pickPort(1),
expectVersion: previous,
label: `previous(${previous})`,
});
if (!before.ok) {
// A broken PREVIOUS version is not this release's fault — degrade to a warning so a
// historically bad publish cannot block the current one.
warnings.push(`previous version ${previous} did not boot cleanly — upgrade path unverified`);
for (const f of before.failures) warn(f);
} else {
const beforeDb = findDb(bData);
const beforeTables = beforeDb ? readTables(beforeDb) : new Set();
log(`previous(${previous}) schema: ${beforeTables.size} tables — upgrading in place`);
npmInstallInto(bPrefix, tarball);
const after = await bootAndProbe({
prefix: bPrefix,
dataDir: bData,
port: pickPort(2),
expectVersion: version,
label: "upgraded",
});
failures.push(...after.failures);
if (after.ok) log(`upgrade healthy on v${after.version}`);
const afterDb = findDb(bData);
upgradedTables = afterDb ? readTables(afterDb) : null;
if (!upgradedTables) {
failures.push("upgraded: database disappeared after the upgrade");
} else {
log(`upgraded schema: ${upgradedTables.size} tables`);
const dropped = [...beforeTables].filter((t) => !upgradedTables.has(t));
if (dropped.length) {
failures.push(`upgrade DROPPED tables that existed before: ${dropped.join(", ")}`);
}
}
}
}
// ---- Schema convergence -----------------------------------------------------
if (freshTables && upgradedTables) {
const verdict = evaluateConvergence({
freshTables,
upgradedTables,
residualAllowlist: allowlist.residualTables ?? {},
});
if (verdict.onlyUpgraded.length) {
warn(`residual tables present only after upgrade: ${verdict.onlyUpgraded.join(", ")}`);
}
failures.push(...verdict.failures);
if (verdict.ok) log("schema convergence OK (no new divergence)");
}
if (warnings.length) for (const w of warnings) warn(w);
if (failures.length) {
console.error(`[install-upgrade] FAIL — ${failures.length} problem(s):`);
for (const f of failures) console.error(`${f}`);
process.exit(1);
}
log("PASS — clean install and upgrade path both boot; schema converges.");
process.exit(0);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
// Only run the (expensive) gate when invoked directly — importing this module for the pure
// helper above must not pack, install or boot anything.
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
main().catch((err) => {
console.error(`[install-upgrade] crashed: ${err?.message ?? err}`);
process.exit(1);
});
}

View File

@@ -9,13 +9,17 @@
// não resolve para um executor válido é um símbolo morto (roteia para fallback
// silencioso em vez de falhar).
//
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
// (2) COMBO STRATEGIES — o despacho DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES INTERNAL_ROUTING_STRATEGY_VALUES
// (src/shared/constants/routingStrategies.ts), exceto as estratégias-default
// implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES (estratégias canônicas
// sem ramo de despacho próprio; caem no ordenamento padrão). Em vez de casar
// literais `strategy === "..."` por regex sobre a fonte, o conjunto tratado
// (handled) vem de uma enumeração em runtime importada de
// open-sse/services/combo/strategyDispatch.ts — o módulo que importa as funções
// reais de ordenação/despacho e lista quais estratégias elas implementam. Adicionar
// um valor canônico sem fiá-lo no despacho/e na enumeração, ou fiar uma string de
// estratégia que não é canônica (inventada), falha aqui.
//
// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de
// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca:
@@ -259,7 +263,7 @@ export function findNewMcpTools(frozen: readonly string[], live: Set<string>): s
* the reason in the commit message.
*
* Sources:
* - MCP_TOOLS (33 base tools: omniroute_* + compression + agent_skills)
* - MCP_TOOLS (34 base tools: omniroute_* + compression + agent_skills)
* - memoryTools (3): omniroute_memory_*
* - skillTools (4): omniroute_skills_*
* - gamificationTools (8): gamification_*
@@ -269,7 +273,7 @@ export function findNewMcpTools(frozen: readonly string[], live: Set<string>): s
* agentSkillTools and compressionTools are included in MCP_TOOLS (deduped by RESERVED_MCP_NAMES).
*/
export const KNOWN_MCP_TOOL_NAMES: readonly string[] = [
// MCP_TOOLS base (33)
// MCP_TOOLS base (34)
"omniroute_get_health",
"omniroute_list_combos",
"omniroute_get_combo_metrics",
@@ -279,6 +283,7 @@ export const KNOWN_MCP_TOOL_NAMES: readonly string[] = [
"omniroute_cost_report",
"omniroute_list_models_catalog",
"omniroute_web_search",
"omniroute_x_search",
"omniroute_simulate_route",
"omniroute_set_budget_guard",
"omniroute_set_routing_strategy",
@@ -483,21 +488,15 @@ async function main(): Promise<void> {
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
];
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
const comboDispatchFiles = [
"open-sse/services/combo.ts",
"open-sse/services/combo/applyStrategyOrdering.ts",
"open-sse/services/combo/resolveAutoStrategy.ts",
// #3501: the fusion/pipeline dispatch branches moved here with the prelude
// extraction; the `strategy === "..."` checks are unchanged, just relocated.
"open-sse/services/combo/dispatchPrelude.ts",
"open-sse/services/combo/targetResolution.ts",
];
const comboSource = comboDispatchFiles
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
.join("\n");
const handled = extractHandledStrategies(comboSource);
// G1: the handled set comes from a runtime-imported dispatch registry that imports the
// actual strategy-ordering functions and enumerates which strategies they implement —
// NOT from regex-scanning `strategy === "..."` literals in source. The old regex broke
// when the dispatch was decomposed (Block J / #3501) and will break again when R0.3
// converts it to a registry; enumerating at runtime keeps the gate correct either way.
// Each entry in HANDLED_COMBO_STRATEGIES must stay in sync with a real dispatch branch.
const strategyDispatchMod =
await import("@omniroute/open-sse/services/combo/strategyDispatch.ts");
const handled = new Set(strategyDispatchMod.HANDLED_COMBO_STRATEGIES as readonly string[]);
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical

View File

@@ -42,12 +42,16 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([
// ---------------------------------------------------------------------------
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
// Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados,
// As migrations Radar 144145, a migration 143 e a 147 já aterrissaram. O job
// registry foi promovido de 139 para 146 pela tabela
// RENAMED_MIGRATION_COMPATIBILITY. A 148 aterrissou nesta branch
// (148_provider_quota_state.sql) e a 149 aterrissou junto com #10066
// (149_api_key_combo_access.sql) — nenhuma das duas é mais um gap. O
// stale-enforcement exige que cada reserva seja removida quando os arquivos
// correspondentes aterrissarem na release.
// ---------------------------------------------------------------------------
export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12); 144/145 aterrissaram na release (radar offers/intel cache), 148/149 aterrissaram (provider_quota_state, api_key_combo_access)
function pad3(n) {
return String(n).padStart(3, "0");

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env node
// scripts/check/check-open-sse-typecheck.mjs
// open-sse workspace typecheck gate (#8781).
//
// The open-sse workspace declares path aliases (e.g. `@/*` → `../src/*`) in its own
// tsconfig.json, but those aliases are not resolvable by Node's bare module resolution —
// they only work because Next.js/Turbopack bundles the entire tree. Additionally,
// package.json historically declared `main`/`exports` entries that do not exist on disk.
//
// This gate runs `tsc -p open-sse/tsconfig.json` and diffs the result against a frozen
// per-file/per-TS-code count baseline (config/quality/open-sse-typecheck-baseline.json),
// following this repo's stale-enforcement allowlist convention. A live count that EXCEEDS
// the baselined count for a given (file, TS code) pair is a regression and fails the gate;
// a live count that is lower is an improvement and does not fail (use --update to ratchet
// the baseline down).
//
// Run:
// node scripts/check/check-open-sse-typecheck.mjs
// node scripts/check/check-open-sse-typecheck.mjs --update # re-freeze baseline
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json");
const BASELINE_PATH = path.join(ROOT, "config/quality/open-sse-typecheck-baseline.json");
const UPDATE = process.argv.includes("--update");
// Matches tsc --pretty false output lines, e.g.:
// src/app/api/v1/chat/route.ts(12,7): error TS2304: Cannot find name 'bar'.
// open-sse/handlers/chatCore.ts(45,3): error TS7053: Element implicitly has an 'any'...
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/;
/**
* Parses raw `tsc --pretty false` stdout into a nested count map:
* { "<relative file path>": { "<TS code>": <count> } }
*
* Pure/exported for unit testing against synthetic tsc output — no child
* process involved here.
*/
export function parseTscOutput(raw) {
const counts = {};
const lines = String(raw).split("\n");
for (const line of lines) {
const match = TSC_ERROR_LINE.exec(line);
if (!match) continue;
const [, file, , , code] = match;
if (!counts[file]) counts[file] = {};
counts[file][code] = (counts[file][code] || 0) + 1;
}
return counts;
}
/**
* Compares live (file, TS code) error counts against a frozen baseline.
* Returns `{ regressions, improvements }`:
* - regressions: entries where live count > baselined count (or the pair is
* entirely new/unbaselined) — these fail the gate.
* - improvements: entries where live count < baselined count — informational,
* do not fail (use --update to ratchet the baseline down).
*
* Exported for unit testing.
*/
export function diffAgainstBaseline(live, baseline) {
const regressions = [];
const improvements = [];
for (const [file, codes] of Object.entries(live)) {
for (const [code, liveCount] of Object.entries(codes)) {
const baselineCount = (baseline[file] && baseline[file][code]) || 0;
if (liveCount > baselineCount) {
regressions.push({ file, code, liveCount, baselineCount });
} else if (liveCount < baselineCount) {
improvements.push({ file, code, liveCount, baselineCount });
}
}
}
for (const [file, codes] of Object.entries(baseline)) {
for (const [code, baselineCount] of Object.entries(codes)) {
const liveCount = (live[file] && live[file][code]) || 0;
if (liveCount === 0 && baselineCount > 0) {
improvements.push({ file, code, liveCount: 0, baselineCount });
}
}
}
return { regressions, improvements };
}
function runTsc() {
try {
const stdout = execFileSync(
process.platform === "win32" ? "npx.cmd" : "npx",
["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT }
);
return stdout;
} catch (err) {
// tsc exits non-zero when there are type errors — stdout still has the report.
if (err.stdout) return String(err.stdout);
throw err;
}
}
function loadBaseline() {
if (!fs.existsSync(BASELINE_PATH)) return {};
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
}
function writeBaseline(counts) {
fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n");
}
function main() {
if (!fs.existsSync(TSCONFIG)) {
process.stderr.write(`[open-sse-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`);
process.exit(2);
}
console.log("[open-sse-typecheck] Running tsc scoped to open-sse/ workspace…");
const stdout = runTsc();
const live = parseTscOutput(stdout);
const baseline = loadBaseline();
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
const liveErrorCount = Object.values(live).reduce(
(sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0),
0
);
console.log(`openSseTypecheckErrors=${liveErrorCount}`);
if (UPDATE) {
writeBaseline(live);
console.log(`[open-sse-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`);
process.exit(0);
}
if (improvements.length > 0) {
console.log(
`[open-sse-typecheck] ${improvements.length} baselined error(s) no longer present ` +
`— run 'node scripts/check/check-open-sse-typecheck.mjs --update' to ratchet the baseline down:\n` +
improvements
.map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`)
.join("\n")
);
}
if (regressions.length > 0) {
process.stderr.write(
`[open-sse-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` +
`under open-sse/ workspace not covered by the frozen baseline:\n` +
regressions
.map((r) => `${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`)
.join("\n") +
`\n\nIf this is a genuine new open-sse type error (e.g. an undeclared @/ alias),\n` +
`fix it in the source, not in the baseline.\n` +
`If it's pre-existing type looseness you're intentionally not fixing in this PR,\n` +
`do NOT widen the baseline for new regressions — that defeats the gate.\n`
);
process.exit(1);
}
console.log(
`[open-sse-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.`
);
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}

View File

@@ -14,12 +14,28 @@
* 0 = boots and reports the right version · 1 = boot failed · 2 = missing build.
*/
import { execFileSync, spawn } from "node:child_process";
import { createHmac } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const POLL_INTERVAL_MS = 2_000;
const BOOT_DEADLINE_MS = 240_000;
const MAX_SERVER_OUTPUT_CHARS = 1_000_000;
const SQLJS_STARTUP_MARKER = "Pre-initializing sql.js WASM";
const DEFAULT_CLI_SALT = "omniroute-cli-auth-v1";
export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([
"dist/node_modules/sql.js/package.json",
"dist/node_modules/sql.js/dist/sql-wasm.js",
"dist/node_modules/sql.js/dist/sql-wasm.wasm",
]);
export const REQUIRED_MACHINE_TOKEN_RUNTIME_FILES = Object.freeze([
"node_modules/node-machine-id/package.json",
"node_modules/node-machine-id/index.js",
]);
/** Parse `npm pack --json` output into the generated tarball filename. */
export function pickTarball(packJsonOutput) {
@@ -49,20 +65,355 @@ export function pickPort(seed = process.pid) {
return 23000 + (seed % 4000);
}
export function findMissingSqlJsRuntimeFiles(packageRoot, exists = fs.existsSync) {
return REQUIRED_SQLJS_RUNTIME_FILES.filter(
(relativePath) => !exists(path.join(packageRoot, relativePath))
);
}
export function findMissingMachineTokenRuntimeFiles(packageRoot, exists = fs.existsSync) {
return REQUIRED_MACHINE_TOKEN_RUNTIME_FILES.filter(
(relativePath) => !exists(path.join(packageRoot, relativePath))
);
}
export function evaluateMachineTokenAuth({
cliToken,
unauthenticatedStatus,
invalidStatus,
authenticatedStatus,
salt = process.env.OMNIROUTE_CLI_SALT || DEFAULT_CLI_SALT,
}) {
const failures = [];
if (!/^[0-9a-f]{64}$/.test(cliToken || "")) {
failures.push("packaged CLI derived an empty or malformed machine token");
}
const emptyMachineIdToken = createHmac("sha256", "").update(salt).digest("hex");
if (cliToken === emptyMachineIdToken) {
failures.push("packaged CLI derived the public empty-machine-id token");
}
if (unauthenticatedStatus !== 401) {
failures.push(`no-credential request returned ${unauthenticatedStatus} (expected 401)`);
}
if (invalidStatus !== 401) {
failures.push(`invalid-token request returned ${invalidStatus} (expected 401)`);
}
if (authenticatedStatus !== 200) {
failures.push(`packaged CLI token request returned ${authenticatedStatus} (expected 200)`);
}
return { ok: failures.length === 0, failures };
}
export function evaluateSqlJsRoundTrip({
startupOutput,
beforeValue,
patchedValue,
readBackValue,
}) {
const failures = [];
if (!startupOutput.includes(SQLJS_STARTUP_MARKER)) {
failures.push("server output did not confirm the forced sql.js startup path");
}
if (patchedValue !== !beforeValue) {
failures.push(
`PATCH debugMode returned ${String(patchedValue)} (expected ${String(!beforeValue)})`
);
}
if (readBackValue !== !beforeValue) {
failures.push(
`GET debugMode returned ${String(readBackValue)} (expected ${String(!beforeValue)})`
);
}
return { ok: failures.length === 0, failures };
}
/**
* After a clean shutdown + restart with the same DATA_DIR, the value written in boot #1
* must be read back from disk in boot #2. sql.js is in-memory with debounced/flush writes,
* so this proves the persisted file actually landed and the restart reads it.
*/
export function evaluateRestartPersistence({ expectedValue, restartValue }) {
const failures = [];
if (restartValue !== expectedValue) {
failures.push(
`restart GET debugMode returned ${String(restartValue)} (expected ${String(expectedValue)} after restart)`
);
}
return { ok: failures.length === 0, failures };
}
async function readJsonResponse(url, options) {
const response = await fetch(url, options);
const body = await response.json().catch(() => null);
return { response, body };
}
async function verifySettingsRoundTrip(baseUrl, startupOutput, cliToken) {
const authHeaders = { "x-omniroute-cli-token": cliToken };
const initial = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders });
if (initial.response.status !== 200 || !initial.body || typeof initial.body !== "object") {
return {
ok: false,
failures: [`initial settings HTTP ${initial.response.status} or non-JSON body`],
};
}
const beforeValue = initial.body.debugMode === true;
const expectedValue = !beforeValue;
const patched = await readJsonResponse(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { ...authHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ debugMode: expectedValue }),
});
if (patched.response.status !== 200 || !patched.body || typeof patched.body !== "object") {
return {
ok: false,
failures: [`settings PATCH HTTP ${patched.response.status} or non-JSON body`],
};
}
const readBack = await readJsonResponse(`${baseUrl}/api/settings`, { headers: authHeaders });
if (readBack.response.status !== 200 || !readBack.body || typeof readBack.body !== "object") {
return {
ok: false,
failures: [`settings read-back HTTP ${readBack.response.status} or non-JSON body`],
};
}
return {
...evaluateSqlJsRoundTrip({
startupOutput,
beforeValue,
patchedValue: patched.body.debugMode,
readBackValue: readBack.body.debugMode,
}),
// The exact value boot #2 must read back from disk to prove persistence.
expectedValue,
};
}
function log(msg) {
console.log(`[pack-boot] ${msg}`);
}
/** Node sets exitCode/signalCode synchronously when the process dies — authoritative. */
function hasExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
/**
* SIGTERM the process GROUP and wait for its REAL exit — the graceful-shutdown handler
* (initGracefulShutdown) drains requests, checkpoints the DB via closeDbInstance(), then
* calls process.exit(0). A fixed sleep + hard kill could SIGKILL mid-flush and silently
* drop the very persistence this gate proves, so SIGKILL is a last resort after the grace
* deadline, and a CONFIRMED exit is required before returning: if even SIGKILL fails to
* reap, throw, so boot #2 cannot start against a port a zombie still holds.
*
* The child is spawned with detached:true, so it leads its own process group and
* -child.pid signals the whole tree, not just the launcher.
*/
async function stopChild(child, graceMs = 30_000) {
if (!child?.pid) return;
// Fast path: already reaped (crashed mid-smoke, or exited before this call) — nothing
// left to signal or wait for.
if (hasExited(child)) return;
let onSettled;
const exited = new Promise((resolve) => {
onSettled = () => resolve();
child.once("exit", onSettled);
child.once("close", onSettled);
});
// Race the exit/close promise against a timeout; then re-read authoritative state, so a
// same-tick exit that lost the race still counts. Timer is always cleared.
const waitForExit = (ms) => {
let timer;
return Promise.race([
exited,
new Promise((resolve) => {
timer = setTimeout(resolve, ms);
}),
])
.finally(() => clearTimeout(timer))
.then(() => hasExited(child));
};
try {
// Re-check AFTER attaching: if the process died in the gap between the fast path and
// listener attach, once("exit") can never fire (event already emitted), and without
// this waitForExit would burn the full grace window.
if (hasExited(child)) return;
try {
process.kill(-child.pid, "SIGTERM");
} catch {
/* group already gone */
}
if (await waitForExit(graceMs)) return;
try {
process.kill(-child.pid, "SIGKILL");
} catch {
/* group already gone */
}
if (!(await waitForExit(5_000))) {
throw new Error(
`[pack-boot] server process group ${child.pid} still alive 5s after SIGKILL — ` +
"refusing to reboot on the same port"
);
}
} finally {
child.removeListener("exit", onSettled);
child.removeListener("close", onSettled);
}
}
/**
* Boot the installed CLI once on an isolated DATA_DIR. The child is spawned detached:true
* so it leads its own process group — stopChild() relies on that to SIGTERM the whole tree.
* The caller owns shutdown so the graceful DB flush lands before teardown.
*/
function spawnServer(binPath, port, dataDir) {
const child = spawn(binPath, ["serve", "--port", String(port), "--log", "--no-open"], {
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000",
API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long",
DISABLE_SQLITE_AUTO_BACKUP: "true",
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
OMNIROUTE_PACK_BOOT_SMOKE: "1",
OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1",
INITIAL_PASSWORD: "pack-boot-machine-token-auth-required",
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const tail = [];
let retainedChars = 0;
const keepTail = (chunk) => {
const text = String(chunk);
tail.push(text);
retainedChars += text.length;
while (retainedChars > MAX_SERVER_OUTPUT_CHARS && tail.length > 1) {
retainedChars -= tail.shift().length;
}
};
child.stdout.on("data", keepTail);
child.stderr.on("data", keepTail);
return { child, tail };
}
function derivePackagedCliToken(packageRoot) {
const cliModuleUrl = pathToFileURL(
path.join(packageRoot, "bin", "cli", "utils", "cliToken.mjs")
).href;
return execFileSync(
process.execPath,
[
"--input-type=module",
"--eval",
"import(process.argv[1]).then(async m => process.stdout.write(await m.getCliToken()))",
cliModuleUrl,
],
{ encoding: "utf8", env: { ...process.env } }
).trim();
}
async function verifyMachineTokenAuth(baseUrl, cliToken) {
const endpoint = `${baseUrl}/api/cli/whoami`;
const unauthenticatedStatus = (await fetch(endpoint)).status;
const invalidStatus = (
await fetch(endpoint, { headers: { "x-omniroute-cli-token": "0".repeat(64) } })
).status;
const authenticatedStatus = (
await fetch(endpoint, { headers: { "x-omniroute-cli-token": cliToken } })
).status;
return evaluateMachineTokenAuth({
cliToken,
unauthenticatedStatus,
invalidStatus,
authenticatedStatus,
});
}
/** Poll /api/monitoring/health until the packed version answers or the boot deadline passes. */
async function waitForHealthy(port, child, expectedVersion, cliToken) {
// Seed from authoritative state (Node sets these synchronously at death), then attach a
// named once-listener, then re-check: a child that died before this call, or in the gap
// before the listener attached, would otherwise never fire "exit" and waste the deadline.
const exitDescriptor = (code, signal) => (signal ? `signal ${signal}` : `code ${code ?? -1}`);
let childExit = hasExited(child) ? exitDescriptor(child.exitCode, child.signalCode) : null;
const onChildExit = (code, signal) => {
childExit = exitDescriptor(code, signal);
};
child.once("exit", onChildExit);
if (hasExited(child)) {
childExit = exitDescriptor(child.exitCode, child.signalCode);
}
const deadline = Date.now() + BOOT_DEADLINE_MS;
let verdict = { ok: false, failures: ["never polled"] };
try {
while (Date.now() < deadline) {
if (childExit !== null) {
return { ok: false, failures: [`process exited (${childExit}) before serving`] };
}
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
headers: { "x-omniroute-cli-token": cliToken },
});
const body = await res.json().catch(() => null);
verdict = evaluateBoot(res.status, body, expectedVersion);
if (verdict.ok) return verdict;
} catch {
// not listening yet — keep polling
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
return verdict;
} finally {
child.removeListener("exit", onChildExit);
}
}
/**
* Read the current debugMode setting and return the EXACT boolean. A missing or non-boolean
* field throws: coercing with `=== true` would read `false` for a malformed response and
* could falsely "pass" persistence whenever the expected value happens to be false.
*/
async function readSettingsDebugMode(baseUrl, cliToken) {
const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`, {
headers: { "x-omniroute-cli-token": cliToken },
});
if (response.status !== 200 || !body || typeof body !== "object") {
throw new Error(`settings GET HTTP ${response.status} or non-JSON body`);
}
if (typeof body.debugMode !== "boolean") {
throw new Error(`settings debugMode is ${typeof body.debugMode} (expected boolean)`);
}
return body.debugMode;
}
async function main() {
const ROOT = process.cwd();
if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) {
console.error("[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)");
console.error(
"[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)"
);
process.exit(2);
}
const expectedVersion = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const expectedVersion = JSON.parse(
fs.readFileSync(path.join(ROOT, "package.json"), "utf8")
).version;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pack-boot-"));
let child = null;
let tail = [];
let exitCode = 1;
let primaryError = null; // a smoke-logic failure: boot/PATCH/GET/restart, or an in-flow stop
let cleanupError = null; // recorded ONLY in finally, ONLY for a final stopChild failure
let shutdownConfirmed = false; // process group confirmed stopped → safe to rm the workspace
try {
log(`packing v${expectedVersion}`);
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
@@ -77,87 +428,136 @@ async function main() {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const packageRoot = path.join(prefix, "lib", "node_modules", "omniroute");
const missingSqlJsFiles = findMissingSqlJsRuntimeFiles(packageRoot);
if (missingSqlJsFiles.length > 0) {
throw new Error(
`installed package is missing the sql.js runtime contract: ${missingSqlJsFiles.join(", ")}`
);
}
log("installed package contains the complete sql.js WASM runtime");
const missingMachineTokenFiles = findMissingMachineTokenRuntimeFiles(packageRoot);
if (missingMachineTokenFiles.length > 0) {
throw new Error(
`installed package is missing the node-machine-id runtime contract: ${missingMachineTokenFiles.join(", ")}`
);
}
log("installed package contains the node-machine-id runtime");
const port = pickPort();
const dataDir = path.join(tmp, "data");
fs.mkdirSync(dataDir, { recursive: true });
const binPath = path.join(prefix, "bin", "omniroute");
log(`booting installed CLI on :${port} (DATA_DIR isolated)…`);
child = spawn(binPath, ["serve", "--port", String(port)], {
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000",
API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long",
DISABLE_SQLITE_AUTO_BACKUP: "true",
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const tail = [];
const keepTail = (chunk) => {
tail.push(String(chunk));
while (tail.length > 80) tail.shift();
};
child.stdout.on("data", keepTail);
child.stderr.on("data", keepTail);
let childExit = null;
child.on("exit", (code) => {
childExit = code ?? -1;
});
const deadline = Date.now() + BOOT_DEADLINE_MS;
let verdict = { ok: false, failures: ["never polled"] };
while (Date.now() < deadline) {
if (childExit !== null) {
verdict = { ok: false, failures: [`process exited with code ${childExit} before serving`] };
break;
}
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
const body = await res.json().catch(() => null);
verdict = evaluateBoot(res.status, body, expectedVersion);
if (verdict.ok) {
log(`healthy: HTTP 200, version ${body.version}, status "${body.status}"`);
break;
}
} catch {
// not listening yet — keep polling
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
const packagedCliToken = derivePackagedCliToken(packageRoot);
// BOOT #1 — boot, prove the forced sql.js tier, PATCH a setting, then shut down cleanly
// so the sql.js adapter's graceful persist actually lands on disk. The in-flow stopChild
// THROWS on failure; that lands in catch as primaryError and boot #2 never starts.
log(`boot #1: installed CLI on :${port} (DATA_DIR isolated)…`);
({ child, tail } = spawnServer(binPath, port, dataDir));
let verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken);
if (verdict.ok) {
log("✅ the packed tarball boots — #7065 class gate green");
exitCode = 0;
} else {
console.error(`[pack-boot] ❌ boot FAILED: ${verdict.failures.join("; ")}`);
console.error("[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n"));
log(`healthy: HTTP 200, version ${expectedVersion}`);
const baseUrl = `http://127.0.0.1:${port}`;
const machineAuth = await verifyMachineTokenAuth(baseUrl, packagedCliToken);
if (!machineAuth.ok) {
verdict = machineAuth;
} else {
log("machine-token auth passed with no/invalid/valid contrast controls");
}
const roundTrip = verdict.ok
? await verifySettingsRoundTrip(baseUrl, tail.join(""), packagedCliToken)
: { ok: false, failures: verdict.failures };
if (roundTrip.ok) {
log("settings write/read succeeded through the forced sql.js driver");
await stopChild(child); // throws here → primaryError; boot #2 is skipped
child = null;
// BOOT #2 — same DATA_DIR, fresh process: the value must be read back FROM DISK.
log("boot #2: rebooting on the same DATA_DIR to prove disk persistence…");
({ child, tail } = spawnServer(binPath, port, dataDir));
verdict = await waitForHealthy(port, child, expectedVersion, packagedCliToken);
if (verdict.ok) {
log(`healthy: HTTP 200, version ${expectedVersion}`);
const restartValue = await readSettingsDebugMode(
`http://127.0.0.1:${port}`,
packagedCliToken
);
const persistence = evaluateRestartPersistence({
expectedValue: roundTrip.expectedValue,
restartValue,
});
if (persistence.ok) {
log("value survived a clean shutdown + restart — disk persistence proven");
await stopChild(child); // throws here → primaryError
child = null;
exitCode = 0;
} else {
verdict = persistence;
}
}
} else {
verdict = roundTrip;
}
}
if (!verdict.ok) {
primaryError = new Error(verdict.failures.join("; "));
exitCode = 1;
}
} catch (e) {
// Every smoke-logic failure — boot/PATCH/GET/restart AND in-flow stopChild throws.
primaryError = e;
exitCode = 1;
} finally {
if (child?.pid) {
// Tear down whatever is still running. This block records ONLY a stopChild failure,
// and never overwrites primaryError.
if (child) {
try {
process.kill(-child.pid, "SIGTERM");
} catch {
/* already gone */
}
await new Promise((r) => setTimeout(r, 2_000));
try {
process.kill(-child.pid, "SIGKILL");
} catch {
/* already gone */
await stopChild(child);
shutdownConfirmed = true;
} catch (e) {
cleanupError = e; // still !shutdownConfirmed → workspace preserved below
}
child = null;
} else {
// Stopped in-flow (already confirmed) or never spawned — nothing left to confirm.
shutdownConfirmed = true;
}
fs.rmSync(tmp, { recursive: true, force: true });
// Remove the workspace ONLY after confirmed shutdown; a process group that refused to
// die keeps its DATA_DIR for diagnosis.
if (shutdownConfirmed) {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
// Report primaryError as the smoke failure; report cleanupError separately. Either one
// fails the gate.
if (primaryError) {
console.error(`[pack-boot] ❌ smoke FAILED: ${primaryError.message}`);
if (tail.length) {
console.error(
"[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n")
);
}
}
if (cleanupError) {
console.error(`[pack-boot] ❌ final shutdown FAILED: ${cleanupError.message}`);
exitCode = 1;
}
if (exitCode === 0) {
log("✅ the packed tarball boots AND persists — #7065 class gate green");
}
if (!shutdownConfirmed) {
console.error(
`[pack-boot] ⚠ process group not confirmed stopped — workspace preserved for diagnosis: ${tmp}`
);
}
process.exit(exitCode);
}
const isDirectRun =
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((e) => {
console.error("[pack-boot] fatal:", e.message);

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
// Refuse a pull request that targets its own head branch.
//
// WHY: PR #8912 has head == base == release/v3.8.50 — a PR from a branch to itself. It has no
// diff, it can never merge, and GitHub keeps it in the queue forever with a full check board
// attached. It survived because nothing looks wrong: the checks are green (there is nothing to
// check), the mergeability just reads "unknown", and it quietly costs review attention and CI
// minutes on every push to that branch.
//
// The check is one field comparison, which is the point — it is cheaper than the confusion.
//
// Usage (in CI, inside a pull_request job):
// HEAD_REF="$GITHUB_HEAD_REF" BASE_REF="$GITHUB_BASE_REF" \
// HEAD_SHA=... BASE_SHA=... node scripts/check/check-pr-self-target.mjs
// Exit: 0 when the PR is well-formed or there is no PR context, 1 when it targets itself.
import fs from "node:fs";
import { fileURLToPath } from "node:url";
/**
* Classify a PR's head/base pair.
*
* Both signals are checked because either alone can be absent: `*_REF` is empty for
* cross-fork events in some contexts, and the SHAs coincide on a freshly branched PR that is
* NOT self-targeting (branch cut, nothing pushed yet) — so an equal-SHA alone must not fail.
* Only an equal REF is conclusive; equal SHAs are reported as a warning.
*/
export function classifyPrTarget({ headRef, baseRef, headSha, baseSha } = {}) {
const hr = String(headRef ?? "").trim();
const br = String(baseRef ?? "").trim();
const hs = String(headSha ?? "").trim();
const bs = String(baseSha ?? "").trim();
if (!hr && !br) return { verdict: "no-pr-context" };
if (hr && br && hr === br) {
return {
verdict: "self-targeting",
reason: `head and base are the same branch (${hr}) — this PR has no diff and can never merge`,
};
}
if (hs && bs && hs === bs) {
// Legitimate right after cutting a branch: the tip has not moved yet. Not a failure.
return {
verdict: "empty-diff",
reason: `head and base point at the same commit (${hs.slice(0, 10)}) — nothing to review yet`,
};
}
return { verdict: "ok" };
}
function main() {
const r = classifyPrTarget({
headRef: process.env.HEAD_REF,
baseRef: process.env.BASE_REF,
headSha: process.env.HEAD_SHA,
baseSha: process.env.BASE_SHA,
});
if (r.verdict === "self-targeting") {
process.stderr.write(
`::error::PR targets its own branch — ${r.reason}.\n` +
`Close it, or repoint the base at the branch you actually want to merge into ` +
`(gh pr edit <N> --base <branch>, then VERIFY with gh pr view <N> --json baseRefName — ` +
`the edit fails silently).\n`
);
return 1;
}
if (r.verdict === "empty-diff") {
process.stdout.write(`::warning::${r.reason}.\n`);
return 0;
}
process.stdout.write(
r.verdict === "no-pr-context"
? "[pr-self-target] no PR context — skipping.\n"
: "[pr-self-target] OK — head and base differ.\n"
);
return 0;
}
if (
process.argv[1] &&
fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))
) {
process.exit(main());
}

View File

@@ -89,9 +89,15 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
// The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts
// (god-file decomposition), so the FP moved with the getMiniMaxUsage signature.
//
// open-sse/executors/zcodeProtocol.ts L302: `clientId: \`omniroute-${process.pid}\``
// is the per-process identifier in the local ZCode app-server handshake. It is
// generated from the process PID, is not an upstream OAuth/client credential, and
// must remain visible in the wire contract. Frozen by file:line:value key.
export const KNOWN_LITERAL_CREDS = new Set([
"open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/executors/zcodeProtocol.ts:302:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential
]);
/**

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
// scripts/check/check-rtl-ratchet.mjs
// RTL layout ratchet. Counts physical directional Tailwind classes in TSX.
//
// tests/unit/ui/rtl-logical-classes.test.tsx pins four high-impact components
// and says so: "#3541 (partial, core layout)". This measures the rest, so the
// remaining backlog cannot grow while it is worked through.
//
// Physical classes (ml/mr/pl/pr/left/right/text-left/border-l/rounded-l ...) do
// not mirror under dir=rtl. Tailwind v4 logical utilities (ms/me/ps/pe/start/
// end/text-start/border-s/rounded-s) do.
//
// Output: rtlPhysicalClasses=N
//
// Advisory by default (exit 0). With --ratchet, reads
// metrics.rtlPhysicalClasses.value from config/quality/quality-baseline.json and
// exits 1 only when the measured count is HIGHER (direction: down).
//
// node scripts/check/check-rtl-ratchet.mjs
// node scripts/check/check-rtl-ratchet.mjs --list # show the worst files
// node scripts/check/check-rtl-ratchet.mjs --ratchet # blocking
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const LIST = process.argv.includes("--list");
const RATCHET = process.argv.includes("--ratchet");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const SCAN_DIRS = ["src", "electron"];
const SKIP = new Set(["node_modules", ".next", "dist", "build", "out", "coverage", ".git"]);
// Physical utilities that govern placement and do not mirror under dir=rtl.
const PHYSICAL =
/(?<![\w-])(?:ml|mr|pl|pr|left|right|border-l|border-r|rounded-l|rounded-r)-[a-z0-9.[\]/]+|(?<![\w-])text-(?:left|right)(?![\w-])/g;
/**
* Count violations in one file's source.
*
* Two exemptions, both direction independent rather than oversights:
* - a class already scoped to an `rtl:` variant, which is a deliberate override
* - `left-…` paired with `right-…` on the same element, which spans both edges
*/
export function countViolations(source) {
let count = 0;
for (const line of source.split("\n")) {
const spansBothEdges = /(?<![\w-])left-[a-z0-9.[\]/]+/.test(line) &&
/(?<![\w-])right-[a-z0-9.[\]/]+/.test(line);
for (const match of line.matchAll(PHYSICAL)) {
const before = line.slice(0, match.index);
if (/rtl:[\w-]*$/.test(before)) continue;
if (spansBothEdges && /^(left|right)-/.test(match[0])) continue;
count += 1;
}
}
return count;
}
function* walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (SKIP.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.name.endsWith(".tsx")) yield full;
}
}
function measure() {
const perFile = [];
let total = 0;
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const n = countViolations(fs.readFileSync(file, "utf-8"));
if (n > 0) {
perFile.push({ file: path.relative(ROOT, file), count: n });
total += n;
}
}
}
perFile.sort((a, b) => b.count - a.count);
return { total, perFile };
}
function main() {
const { total, perFile } = measure();
console.log(`rtlPhysicalClasses=${total}`);
if (LIST) {
for (const { file, count } of perFile.slice(0, 25)) {
console.log(` ${String(count).padStart(4)} ${file}`);
}
console.log(` ${perFile.length} file(s) affected`);
}
if (!RATCHET) return 0;
let baseline;
try {
const json = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf-8"));
baseline = json?.metrics?.rtlPhysicalClasses?.value;
} catch (err) {
// A measurement failure must not block, only a measured regression.
if (!QUIET) console.log(`rtlPhysicalClasses=SKIP reason=baseline-unreadable (${err.message})`);
return 0;
}
if (typeof baseline !== "number") {
if (!QUIET) console.log("rtlPhysicalClasses=SKIP reason=baseline-absent");
return 0;
}
if (total > baseline) {
console.error(
`RTL ratchet: ${total} physical directional classes, baseline ${baseline}. ` +
`Use logical utilities (ms/me/ps/pe/start/end/text-start) so the layout ` +
`mirrors under dir=rtl, or re-baseline with justification.`,
);
return 1;
}
if (!QUIET) console.log(`rtlPhysicalClasses OK (${total} <= ${baseline})`);
return 0;
}
// Only run when invoked directly, so countViolations can be unit tested.
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exit(main());
}

View File

@@ -15,6 +15,12 @@ if (!support.nodeCompatible) {
process.exit(1);
}
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
if (process.versions.bun) {
console.log(
`Bun ${process.versions.bun} (${support.nodeVersion}) satisfies OmniRoute secure runtime policy.`
);
} else {
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
}

View File

@@ -107,17 +107,49 @@ export const COLLECTORS = [
glob: "open-sse/services/__tests__/antigravity-quota-family.test.ts",
sources: ["vitest.mcp.config.ts"],
},
// #8890 landed this suite here without wiring a runner, so it had never run once.
{
glob: "open-sse/services/__tests__/fail-fast-concurrency-gate.test.ts",
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/lib/memory/__tests__/generic-backend.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/app/(dashboard)/**/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
// vitest.config.ts via test:vitest:ui (roda com path-filter `tests/unit/ui`, então o
// conjunto EFETIVO é a interseção do include `tests/unit/**/*.test.tsx` com o filtro)
// vitest.config.ts via test:vitest:ui. The script uses the config-wide include list.
{
glob: "tests/unit/ui/**/*.test.tsx",
glob: "tests/unit/**/*.test.tsx",
sources: ["package.json", "vitest.config.ts"],
anchors: { "package.json": "tests/unit/ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" },
anchors: { "package.json": "test:vitest:ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" },
},
// vitest.config.ts include — open-sse/__tests__ files collected by vitest.config.ts.
// These were previously listed as orphans because the COLLECTORS only modelled the
// tests/unit/**/*.test.tsx include; the open-sse globs were missing. Both the top-level
// glob and the more-specific services sub-path glob from vitest.config.ts are listed so
// the drift-check anchors remain exact matches to the config file text.
{
glob: "open-sse/**/__tests__/**/*.test.ts",
sources: ["vitest.config.ts"],
anchors: { "vitest.config.ts": "open-sse/**/__tests__/**/*.test.ts" },
},
// vitest.config.ts include — src/lib/memory and src/lib/skills __tests__ collected by vitest.config.ts.
{
glob: "src/lib/memory/__tests__/**/*.test.ts",
sources: ["vitest.config.ts"],
anchors: { "vitest.config.ts": "src/lib/memory/__tests__/**/*.test.ts" },
},
{
glob: "src/lib/skills/__tests__/**/*.test.ts",
sources: ["vitest.config.ts"],
anchors: { "vitest.config.ts": "src/lib/skills/__tests__/**/*.test.ts" },
},
// vitest.config.ts include — single-file entry for the .test.ts encryption file.
{
glob: "tests/unit/encryption.test.ts",
sources: ["vitest.config.ts"],
anchors: { "vitest.config.ts": "tests/unit/encryption.test.ts" },
},
// Playwright — test:e2e (o script passa tests/e2e/*.spec.ts; testMatch **/*.spec.ts)
{ glob: "tests/e2e/*.spec.ts", sources: ["package.json"] },

View File

@@ -106,9 +106,8 @@ function normalizeWhitespace(s) {
*/
export function countSignificantTokens(cond) {
const tokens =
(cond || "").match(
/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g
) || [];
(cond || "").match(/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g) ||
[];
let count = 0;
for (const tk of tokens) {
if (/^[A-Za-z_$]/.test(tk)) {
@@ -178,8 +177,7 @@ export function extractProdConditions(src) {
}
// Comparison-bearing ternaries: `<lhs> <cmp> <rhs> ? … : …` (best-effort, low-noise).
const ternRe =
/([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
const ternRe = /([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
let t;
while ((t = ternRe.exec(src))) {
pushCond(t[1], ownerAt(t.index));
@@ -199,7 +197,10 @@ export function extractImports(src) {
if (!src) return names;
const addModule = (mod) => {
names.add(mod);
const base = mod.split("/").pop().replace(/\.\w+$/, "");
const base = mod
.split("/")
.pop()
.replace(/\.\w+$/, "");
if (base) names.add(base);
};
let m;
@@ -227,8 +228,7 @@ export function extractImports(src) {
export function findReimplementedConditions(prodSources, testSource, testImports) {
const flags = [];
if (!testSource) return flags;
const imports =
testImports instanceof Set ? testImports : new Set(testImports || []);
const imports = testImports instanceof Set ? testImports : new Set(testImports || []);
const squash = (s) => (s || "").replace(/\s+/g, "");
const testSq = squash(testSource);
const seen = new Set();
@@ -251,15 +251,27 @@ export function findReimplementedConditions(prodSources, testSource, testImports
* (filtro D do git diff --diff-filter=MDR).
*
* `deletionAllowlist` (`_deletedWithReplacement` no test-masking-allowlist.json)
* isenta uma deleção SOMENTE quando o substituto declarado existe no HEAD e é
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem rename
* detectável" (conteúdo novo demais para o -M do git). Qualquer entrada cujo
* substituto não exista ou não seja teste continua flagada.
* isenta uma deleção de três formas, cada uma com sua própria verificação:
* 1. `replacement` (path string) — o substituto declarado existe no HEAD e é
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem
* rename detectável" (conteúdo novo demais para o -M do git).
* 2. `sourceRemoved` (array de paths) — feature removida por completo: TODOS
* os arquivos de produção listados precisam estar ausentes no HEAD (sem
* substituto porque não há mais código a testar). Usar apenas quando a
* remoção do código-fonte está confirmada na mesma commit/PR.
* 3. `strayFromCommit` (hash) + `reason` (não-vazio) — o arquivo entrou no
* repositório POR ACIDENTE no commit declarado (ex.: um commit de docs
* que varreu artefatos de worktree de outra sessão, caso f4e93f339d) e a
* deleção devolve o arquivo ao seu fluxo dono (um PR/issue aberto). O
* gate verifica via git que o commit declarado é exatamente o que ADICIONOU
* o arquivo; o `reason` deve nomear o PR/issue dono para a revisão humana.
* Qualquer entrada cuja condição declarada não se verifique continua flagada.
*/
export function evaluateDeletedFiles(
deletedPaths,
deletionAllowlist = {},
fileExists = fs.existsSync
fileExists = fs.existsSync,
addedByCommit = lookupAddedByCommit
) {
const flags = [];
for (const f of deletedPaths) {
@@ -272,6 +284,29 @@ export function evaluateDeletedFiles(
);
continue;
}
if (entry && Array.isArray(entry.sourceRemoved) && entry.sourceRemoved.length > 0) {
const stillPresent = entry.sourceRemoved.filter((p) => fileExists(p));
if (stillPresent.length === 0) continue;
flags.push(
`${f}: deleção allowlistada como feature removida mas ${stillPresent.join(", ")} ainda existe(m) no HEAD`
);
continue;
}
if (entry && typeof entry.strayFromCommit === "string" && entry.strayFromCommit.trim()) {
if (typeof entry.reason !== "string" || !entry.reason.trim()) {
flags.push(
`${f}: deleção allowlistada como stray mas sem \`reason\` — nomeie o PR/issue dono do arquivo`
);
continue;
}
const actual = addedByCommit(f);
const declared = entry.strayFromCommit.trim();
if (actual && (actual === declared || actual.startsWith(declared))) continue;
flags.push(
`${f}: deleção allowlistada como stray de ${declared} mas o commit que adicionou o arquivo é ${actual ?? "desconhecido"}`
);
continue;
}
flags.push(
`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`
);
@@ -279,6 +314,26 @@ export function evaluateDeletedFiles(
return flags;
}
/**
* (subcheck 1, forma 3) Hash COMPLETO do commit que adicionou `path` (o add
* mais recente — cobre o caso deletado-e-readicionado). `null` quando o git
* não conhece o path.
*/
function lookupAddedByCommit(path) {
try {
const out = execFileSync("git", ["log", "--diff-filter=A", "--format=%H", "--", path], {
encoding: "utf8",
});
const hashes = out
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
return hashes.length ? hashes[0] : null;
} catch {
return null;
}
}
/**
* Parse `git diff --name-status -M --diff-filter=DR` output, separating TRUE
* test-file deletions ("D\tpath") from RENAMES ("R<score>\told\tnew").
@@ -436,6 +491,22 @@ function resolveBase() {
return null;
}
/**
* Whether the per-file diff subchecks should be skipped for being too large to be a
* reviewable unit. Exported so the threshold behavior is testable without a repo: the
* boundary is what matters, and an off-by-one here either blocks a release or silently
* disables the check on a big-but-legitimate PR.
*
* `max <= 0` disables the skip entirely (always analyze) — a deliberate escape hatch.
*/
export function shouldSkipDiffSubchecks(changedCount, max) {
const n = Number(changedCount);
const cap = Number(max);
if (!Number.isFinite(n) || n < 0) return false;
if (!Number.isFinite(cap) || cap <= 0) return false;
return n > cap;
}
function main() {
// (#6404) Absolute floor scan — runs unconditionally, PR or not, so a tautology
// that is already merged into the base (and thus invisible to the diff-only
@@ -507,6 +578,34 @@ function main() {
.map((s) => s.trim())
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
// (gap 6) A release PR is not a reviewable unit, and this is where that stops being free.
// Releases squash-merge into `main`, so a release PR's merge-base is the PREVIOUS cycle's
// fork point and the diff spans the whole cycle. In the v3.8.49 run that was ~1277 changed
// test files, each costing a `git show base:file` process plus a full regex pass — the check
// ran twice without finishing, >30 min pegged on one core, and the release waited on it.
//
// Every one of those files was already gated by this same check on its own PR during the
// cycle. Re-analyzing the aggregate buys nothing and blocks the release, so above the
// threshold the per-file diff subchecks are skipped — LOUDLY, naming the count, because a
// silent skip is how a gate becomes indistinguishable from a passing one (that is gap 12,
// and it cost two production bugs this cycle).
//
// The floor is untouched: scanBareTautologies() above already ran unconditionally over all
// tracked test files (3977 files, ~1 s), so nothing here lowers absolute coverage.
const maxChangedTests = Number(process.env.TEST_MASKING_MAX_CHANGED_TESTS || 300);
if (shouldSkipDiffSubchecks(changed.length + renamePerFile.length, maxChangedTests)) {
console.log(
`[test-masking] ${changed.length} teste(s) modificado(s) + ${renamePerFile.length} ` +
`renomeado(s) excede o teto de ${maxChangedTests} — pulando os subchecks de diff.\n` +
` Um diff desse tamanho é um PR de release (base = main, merge-base = fork do ciclo ` +
`anterior por causa do squash), não uma unidade revisável.\n` +
` Cada um desses arquivos já passou por este mesmo gate no PR de origem.\n` +
` O scan absoluto de tautologias rodou sobre TODOS os testes rastreados e está OK.\n` +
` Para forçar a análise completa: TEST_MASKING_MAX_CHANGED_TESTS=999999`
);
return;
}
const perFile = [...renamePerFile];
for (const file of changed) {
const baseSrc = git(["show", `${base}:${file}`]);

View File

@@ -1,12 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests).
// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest".
// Dirs collected ONLY by Vitest (vitest.mcp.config.ts and vitest.config.ts).
// Keep in sync with both configs. A test here MUST import from "vitest".
const VITEST_ONLY_DIRS = [
"tests/unit/autoCombo",
"open-sse/services/autoCombo",
"open-sse/mcp-server",
"open-sse/services/__tests__",
"open-sse/translator/helpers/__tests__",
"src/lib/memory/__tests__",
"src/lib/skills/__tests__",
];
function walk(dir, root, out = []) {
@@ -47,7 +52,7 @@ export function findRunnerMismatches(root) {
return bad;
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
const root = process.cwd();
const bad = findRunnerMismatches(root);
if (bad.length) {

View File

@@ -10,14 +10,39 @@
// - coverage/ — relatórios de cobertura gerados pelo c8
// - quality-metrics.json — saída do collect-metrics.mjs (gerado, não-versionado)
// - symlinks rastreados (mode 120000) — indício de `git add -A` em worktree
// - _tasks (exato E prefixo) — repo git SEPARADO; o blob symlink rastreado causou DOIS
// wipes do diretório real (2026-08-08 e 2026-08-10; Hard Rule #23)
// - _references/ _mono_repo/ _ideia/ _cache/ — diretórios privados de raiz (regra /_*/)
// - .claude/worktrees/ — worktrees de sessão nunca entram no repo
// - docs/superpowers/ — artefatos de planejamento vivem em _tasks/, não em docs/
// - .eslintcache* .fakebin-* dist/ .build/ .artifacts/ logs/ — caches e outputs gerados
//
// Todos os prefixos são ancorados na raiz (startsWith sobre paths do `git ls-files`):
// paths aninhados legítimos como `src/lib/logs/` NÃO são atingidos.
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const FORBIDDEN_PREFIXES = ["node_modules/", ".next/", "coverage/"];
const FORBIDDEN_PREFIXES = [
"node_modules/",
".next/",
"coverage/",
// "_" na raiz é GENÉRICO (regra abaixo em checkTrackedArtifacts): _tasks/, _references/,
// _mono_repo/, _ideia/, _cache/ e qualquer _<novo>/ futuro — dirs privados, alguns com
// repo git próprio (_tasks). Nunca rastrear nada dentro deles (Hard Rule #23).
".claude/worktrees/",
"docs/superpowers/",
".eslintcache", // matches .eslintcache, .eslintcache-complexity, .eslintcache-probe, …
".fakebin-", // test executable shim dirs (.fakebin-<pid>/)
"dist/",
".build/",
".artifacts/",
"logs/",
];
const FORBIDDEN_EXACT = new Set([
"quality-metrics.json", // legacy root location (still forbidden if a stale run writes it)
"config/quality/quality-metrics.json", // current generated location (collect-metrics.mjs)
"_tasks", // separate git repo — a tracked blob/symlink here wiped the real dir twice (HR#23)
]);
/**
@@ -36,6 +61,13 @@ export function checkTrackedArtifacts(trackedFiles, trackedSymlinks = []) {
violations.push(`forbidden tracked artifact: ${file}`);
continue;
}
// Regra genérica: NENHUM caminho de raiz prefixado com "_" pode ser rastreado
// (dir ou arquivo). Cobre _tasks, _references, _mono_repo e qualquer _<novo> futuro;
// paths aninhados legítimos (src/lib/_x) não são atingidos.
if (file.startsWith("_")) {
violations.push(`forbidden tracked artifact (root underscore path): ${file}`);
continue;
}
for (const prefix of FORBIDDEN_PREFIXES) {
if (file.startsWith(prefix)) {
violations.push(`forbidden tracked artifact (${prefix}*): ${file}`);

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env node
// Blocks TypeScript 7 diagnostic regressions without requiring the existing
// migration backlog to be clean. The PR base and checked-out head are compiled
// with the same compiler and tsconfig, then compared as duplicate-preserving
// multisets of: relative file | TS code | normalized message.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const DEFAULT_TSCONFIG = "tsconfig.typecheck-core.json";
const DEFAULT_COMPILER_VERSION = "7.0.2";
const DIAGNOSTIC_START = /^(.+?)\((\d+),(\d+)\): error (TS\d+):\s*(.*)$/;
const GLOBAL_DIAGNOSTIC_START = /^error (TS\d+):\s*(.*)$/;
function normalizeSlashes(value) {
return String(value).replaceAll("\\", "/");
}
function stripRoot(value, root) {
const normalizedValue = normalizeSlashes(value);
const normalizedRoot = normalizeSlashes(path.resolve(root)).replace(/\/$/, "");
return normalizedValue === normalizedRoot
? "."
: normalizedValue.startsWith(`${normalizedRoot}/`)
? normalizedValue.slice(normalizedRoot.length + 1)
: normalizedValue;
}
export function normalizeDiagnosticMessage(message, root = ROOT) {
const normalizedRoot = normalizeSlashes(path.resolve(root)).replace(/\/$/, "");
return normalizeSlashes(message)
.replaceAll(normalizedRoot, "<repo>")
.replace(/((?:[A-Za-z]:)?[^()\s]+\.(?:[cm]?[jt]sx?|json))\(\d+,\d+\)/gi, "$1")
.replace(/\s+/g, " ")
.trim();
}
/** Parse complete `tsc --pretty false` diagnostic blocks. */
export function parseTscDiagnostics(raw, { root = ROOT } = {}) {
const diagnostics = [];
let current = null;
const flush = () => {
if (!current) return;
const message = normalizeDiagnosticMessage(current.messageLines.join("\n"), root);
diagnostics.push({
file: current.file,
code: current.code,
message,
key: `${current.file}\u0000${current.code}\u0000${message}`,
});
current = null;
};
for (const line of String(raw).split(/\r?\n/)) {
const located = DIAGNOSTIC_START.exec(line);
if (located) {
flush();
current = {
file: stripRoot(located[1], root),
code: located[4],
messageLines: [located[5]],
};
continue;
}
const global = GLOBAL_DIAGNOSTIC_START.exec(line);
if (global) {
flush();
current = { file: "<global>", code: global[1], messageLines: [global[2]] };
continue;
}
if (current && /^\s/.test(line) && line.trim()) current.messageLines.push(line);
}
flush();
return diagnostics;
}
export function toDiagnosticMultiset(diagnostics) {
const counts = new Map();
for (const diagnostic of diagnostics) {
const entry = counts.get(diagnostic.key) ?? { ...diagnostic, count: 0 };
entry.count += 1;
counts.set(diagnostic.key, entry);
}
return counts;
}
export function diffDiagnosticMultisets(baseDiagnostics, headDiagnostics) {
const base = toDiagnosticMultiset(baseDiagnostics);
const head = toDiagnosticMultiset(headDiagnostics);
const added = [];
const removed = [];
for (const [key, entry] of head) {
const baseCount = base.get(key)?.count ?? 0;
if (entry.count > baseCount) {
added.push({ ...entry, baseCount, headCount: entry.count, delta: entry.count - baseCount });
}
}
for (const [key, entry] of base) {
const headCount = head.get(key)?.count ?? 0;
if (entry.count > headCount) {
removed.push({ ...entry, baseCount: entry.count, headCount, delta: entry.count - headCount });
}
}
const order = (a, b) => a.key.localeCompare(b.key);
return { added: added.sort(order), removed: removed.sort(order) };
}
export function hasParserPrerequisite(diagnostics) {
return diagnostics.some((diagnostic) => diagnostic.code === "TS1005");
}
function argument(name, fallback = "") {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
function run(command, args, options = {}) {
return spawnSync(command, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
...options,
});
}
function resolveCommit(ref) {
const result = run("git", ["rev-parse", "--verify", `${ref}^{commit}`]);
if (result.status !== 0) {
throw new Error(`cannot resolve base ref ${ref}: ${result.stderr.trim()}`);
}
return result.stdout.trim();
}
function sameLockfile(baseRoot) {
const head = path.join(ROOT, "package-lock.json");
const base = path.join(baseRoot, "package-lock.json");
return (
fs.existsSync(head) &&
fs.existsSync(base) &&
fs.readFileSync(head).equals(fs.readFileSync(base))
);
}
function linkDependencies(baseRoot) {
const source = path.join(ROOT, "node_modules");
const target = path.join(baseRoot, "node_modules");
if (!fs.existsSync(source)) throw new Error("node_modules is missing; run npm ci first");
fs.mkdirSync(target);
for (const entry of fs.readdirSync(source)) {
if (entry === "@omniroute") continue;
fs.symlinkSync(path.join(source, entry), path.join(target, entry), "junction");
}
const scope = path.join(target, "@omniroute");
fs.mkdirSync(scope);
fs.symlinkSync(path.join(baseRoot, "open-sse"), path.join(scope, "open-sse"), "junction");
fs.symlinkSync(
path.join(baseRoot, "packages", "browser-pool"),
path.join(scope, "browser-pool"),
"junction"
);
}
function installBaseDependencies(baseRoot) {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = run(
npm,
["ci", "--ignore-scripts", "--prefer-offline", "--no-audit", "--no-fund"],
{ cwd: baseRoot, stdio: "inherit" }
);
if (result.status !== 0) throw new Error(`npm ci for the base worktree exited ${result.status}`);
}
function runTypeScript(root, tsconfig, compilerVersion) {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = run(
npm,
[
"exec",
"--yes",
`--package=typescript@${compilerVersion}`,
"--",
"tsc",
"--pretty",
"false",
"--noEmit",
"-p",
tsconfig,
],
{ cwd: root }
);
if (result.error) throw result.error;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
const diagnostics = parseTscDiagnostics(output, { root });
if (result.status !== 0 && diagnostics.length === 0) {
throw new Error(
`TypeScript exited ${result.status} without a parseable diagnostic:\n${output}`
);
}
return { diagnostics, status: result.status ?? 0 };
}
function formatEntry(entry) {
return `${entry.file} ${entry.code}: ${entry.message} (${entry.baseCount} -> ${entry.headCount})`;
}
function appendSummary({ baseRef, baseCount, headCount, added, removed, skipped }) {
const summary = process.env.GITHUB_STEP_SUMMARY;
if (!summary) return;
const lines = [
"## TypeScript 7 zero-new-diagnostics ratchet",
"",
`- Base: \`${baseRef}\` (${baseCount} diagnostics)`,
`- Head: ${headCount} diagnostics`,
`- Added: ${added.reduce((sum, entry) => sum + entry.delta, 0)}`,
`- Removed: ${removed.reduce((sum, entry) => sum + entry.delta, 0)}`,
];
if (skipped) lines.push("- Status: parser prerequisite unresolved; comparison is advisory");
if (added.length) {
lines.push("", "### Added diagnostics", "", ...added.map((entry) => `- ${formatEntry(entry)}`));
}
fs.appendFileSync(summary, `${lines.join("\n")}\n`);
}
function main() {
const baseRef = argument("--base-ref", process.env.TS7_BASE_REF ?? "");
const tsconfig = argument("--tsconfig", DEFAULT_TSCONFIG);
const compilerVersion = argument("--compiler-version", DEFAULT_COMPILER_VERSION);
if (!baseRef) {
console.log("[ts7-ratchet] SKIP — --base-ref is required outside a pull request");
return 0;
}
if (!fs.existsSync(path.join(ROOT, tsconfig))) {
throw new Error(`tsconfig not found: ${tsconfig}`);
}
const baseCommit = resolveCommit(baseRef);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ts7-ratchet-"));
const baseRoot = path.join(temporaryRoot, "base");
let worktreeAdded = false;
try {
const add = run("git", ["worktree", "add", "--detach", baseRoot, baseCommit]);
if (add.status !== 0) throw new Error(`cannot create base worktree: ${add.stderr.trim()}`);
worktreeAdded = true;
if (sameLockfile(baseRoot)) linkDependencies(baseRoot);
else installBaseDependencies(baseRoot);
console.log(
`[ts7-ratchet] TypeScript ${compilerVersion}; base=${baseCommit}; config=${tsconfig}`
);
const base = runTypeScript(baseRoot, tsconfig, compilerVersion);
const head = runTypeScript(ROOT, tsconfig, compilerVersion);
const { added, removed } = diffDiagnosticMultisets(base.diagnostics, head.diagnostics);
const parserBlocked = hasParserPrerequisite(base.diagnostics);
console.log(`ts7DiagnosticsBase=${base.diagnostics.length}`);
console.log(`ts7DiagnosticsHead=${head.diagnostics.length}`);
console.log(`ts7DiagnosticsAdded=${added.reduce((sum, entry) => sum + entry.delta, 0)}`);
console.log(`ts7DiagnosticsRemoved=${removed.reduce((sum, entry) => sum + entry.delta, 0)}`);
appendSummary({
baseRef: baseCommit,
baseCount: base.diagnostics.length,
headCount: head.diagnostics.length,
added,
removed,
skipped: parserBlocked,
});
if (parserBlocked) {
console.warn(
"[ts7-ratchet] SKIP — the release base still has TS1005 parser diagnostics. " +
"Resolve #10094 before making this comparison blocking; those errors are not accepted as baseline."
);
return 0;
}
if (added.length) {
console.error(
`[ts7-ratchet] FAIL — the PR adds ${added.reduce((sum, entry) => sum + entry.delta, 0)} ` +
`normalized TypeScript 7 diagnostic(s):\n${added.map((entry) => `${formatEntry(entry)}`).join("\n")}`
);
return 1;
}
console.log(
`[ts7-ratchet] OK — no new normalized diagnostics; ` +
`${removed.reduce((sum, entry) => sum + entry.delta, 0)} removed.`
);
return 0;
} finally {
if (worktreeAdded) {
const remove = run("git", ["worktree", "remove", "--force", baseRoot]);
if (remove.status !== 0) {
console.warn(`[ts7-ratchet] WARN — temporary worktree cleanup: ${remove.stderr.trim()}`);
}
run("git", ["worktree", "prune"]);
}
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
process.exitCode = main();
} catch (error) {
console.error(`[ts7-ratchet] FAIL — ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 2;
}
}

View File

@@ -235,6 +235,23 @@ export function runActionlint(files) {
* @param {string} workflowsDir - Path to .github/workflows
* @returns {{ count: number, diagnostics: unknown[], skipped: boolean }}
*/
/**
* The zizmor version actually doing the auditing, or "unknown".
*
* Emitted next to the count because the two must be read together. The GitHub runner measured
* 1 finding MORE than the devbox on the identical commit (190 vs 189) during the v3.8.49 cycle,
* which cost a second rebaseline push: CI installed whatever PyPI served that day while the
* devbox had an older build. A count without the version that produced it is not a
* reproducible number, and rebaselining against it just moves the disagreement.
*/
export function zizmorVersion() {
try {
return execFileSync("zizmor", ["--version"], { encoding: "utf8" }).trim() || "unknown";
} catch {
return "unknown";
}
}
export function runZizmor(workflowsDir) {
const args = ["--format", "json"];
if (fs.existsSync(ZIZMOR_CONFIG)) {
@@ -337,6 +354,9 @@ function main() {
process.stdout.write(`workflowFindings=${total}\n`);
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
process.stdout.write(`zizmorFindings=${zizmorCount}\n`);
// Read this line with the count above: a finding total is only reproducible against the
// version that produced it. See zizmorVersion().
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
if (STRICT && total > 0) {
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { CLI_TOKEN_HEADER, getCliToken } from "../../bin/cli/utils/cliToken.mjs";
const baseUrl = (process.env.OMNIROUTE_BASE_URL || "http://127.0.0.1:20128").replace(/\/$/, "");
const apiKey = process.env.OMNIROUTE_API_KEY || "";
const timeoutMs = 5000;
async function get(path) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let hardTimer;
const hardTimeout = new Promise((_, reject) => {
hardTimer = setTimeout(
() => reject(new Error(`request timeout after ${timeoutMs}ms`)),
timeoutMs + 100
);
});
try {
const response = await Promise.race([
fetch(`${baseUrl}${path}`, {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
[CLI_TOKEN_HEADER]: await getCliToken(),
},
signal: controller.signal,
}),
hardTimeout,
]);
const body = await response.json().catch(() => null);
return { ok: response.ok, status: response.status, body };
} finally {
clearTimeout(timer);
clearTimeout(hardTimer);
}
}
function check(label, passed, detail = "") {
console.log(`${label}: ${passed ? "PASS" : "FAIL"}${detail ? ` (${detail})` : ""}`);
return passed;
}
console.log("OmniRoute Verification");
console.log(`Gateway: ${baseUrl}`);
const results = [];
try {
const models = await get("/v1/models");
results.push(check("Gateway", models.ok, `HTTP ${models.status}`));
const modelCount = Array.isArray(models.body?.data) ? models.body.data.length : 0;
results.push(check("Catalog", modelCount > 0, `${modelCount} models`));
const pools = await get("/api/quota/pools");
const poolRows = Array.isArray(pools.body?.pools) ? pools.body.pools : [];
const allocations = poolRows.reduce((sum, pool) => sum + (pool.allocations?.length || 0), 0);
results.push(check("Pools", pools.ok, `${poolRows.length}`));
results.push(check("Allocations", pools.ok && allocations >= poolRows.length, `${allocations}`));
const status = await get("/api/omniroute/status");
results.push(check("Status API", status.ok, `HTTP ${status.status}`));
results.push(check("No live request", status.body?.liveRequestExecuted === false));
} catch (error) {
results.push(
check("Verification", false, error instanceof Error ? error.message : String(error))
);
}
console.log(`Live upstream requests: 0`);
if (results.some((passed) => !passed)) process.exitCode = 1;

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Resolve the Docker tag/channel for a docker-publish workflow event.
#
# Usage:
# resolve-docker-publish-version.sh EVENT_NAME REF_TYPE REF_NAME [INPUT_VERSION] [DEFAULT_BRANCH]
#
# Outputs exactly one safe tag string:
# - workflow_dispatch: requested version without a leading v
# - push tag: tag without a leading v
# - push main: main
# - push to the current default release/v* branch: next
# - release: release tag without a leading v
set -euo pipefail
EVENT_NAME="${1:?event name required}"
REF_TYPE="${2:-}"
REF_NAME="${3:-}"
INPUT_VERSION="${4:-}"
DEFAULT_BRANCH="${5:-}"
case "$EVENT_NAME" in
workflow_dispatch)
VERSION="${INPUT_VERSION#v}"
;;
push)
if [ "$REF_TYPE" = "tag" ]; then
VERSION="${REF_NAME#v}"
else
case "$REF_NAME" in
main)
VERSION="main"
;;
release/v*)
if [ -z "$DEFAULT_BRANCH" ] || [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then
echo "Refusing to publish next from non-default release branch: $REF_NAME" >&2
exit 1
fi
VERSION="next"
;;
*)
echo "Unsupported Docker publish branch: $REF_NAME" >&2
exit 1
;;
esac
fi
;;
release)
VERSION="${REF_NAME#v}"
;;
*)
VERSION="${REF_NAME#v}"
;;
esac
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
exit 1
fi
printf '%s\n' "$VERSION"

View File

@@ -22,11 +22,16 @@ set -euo pipefail
VERSION="${1:?version required}"
# A pre-release VERSION must never grab :latest (callers already short-circuit
# this, but stay safe as a standalone unit).
case "$VERSION" in
*-*) echo "false"; exit 0 ;;
esac
# Only a stable x.y.z release may ever grab :latest. Floating channels such as
# `main` and `next`, plus every pre-release identifier, fail closed here even if
# a caller forgets to short-circuit them first.
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
# Consume the caller's tag stream before exiting. A pre-release decision is
# immediate, but closing stdin early can give a piped producer EPIPE.
cat >/dev/null
echo "false"
exit 0
fi
# Build the stable candidate set: incoming tags (v-stripped, pre-releases
# dropped) plus VERSION itself, then pick the numerically highest.

View File

@@ -11,7 +11,7 @@ import * as yaml from "js-yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/openapi.yaml");
const OUT_DIR = join(ROOT, "bin/cli/api-commands");
const OUT_DIR = process.env.OPENAPI_OUT_DIR || join(ROOT, "bin/cli/api-commands");
// Operations already covered by hand-crafted commands — skip in generated output.
const IGNORED_OP_IDS = new Set([
@@ -51,6 +51,29 @@ if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const spec = yaml.load(readFileSync(SPEC_PATH, "utf8"));
// Minimal, scoped $ref resolver — only follows refs into components/parameters.
// This is not a generic dereferencer (no cycle handling, no cross-file refs):
// OpenAPI `parameters` entries in this spec only ever $ref a component parameter
// (see docs/openapi.yaml → components/parameters/ResourceId), so a full
// dereferencer would be scope creep. Without this, `p.in === "path"` silently
// drops every $ref'd path parameter (a bare `{ $ref }` object has no `.in`),
// which is what let generated PATCH/DELETE combo commands lose --id (#10955).
const PARAM_REF_PREFIX = "#/components/parameters/";
function resolveParam(p) {
if (p && typeof p === "object" && typeof p.$ref === "string") {
if (!p.$ref.startsWith(PARAM_REF_PREFIX)) {
throw new Error(`Unsupported parameter $ref (only ${PARAM_REF_PREFIX}* is resolved): ${p.$ref}`);
}
const name = p.$ref.slice(PARAM_REF_PREFIX.length);
const resolved = spec.components?.parameters?.[name];
if (!resolved) {
throw new Error(`Unresolvable parameter $ref: ${p.$ref}`);
}
return resolved;
}
return p;
}
/** @type {Record<string, Array<{path: string, method: string, opId: string, op: object}>>} */
const byTag = {};
@@ -89,7 +112,7 @@ for (const [tag, ops] of Object.entries(byTag)) {
for (const { path, method, opId, op } of ops) {
const cmdName = kebab(opId);
const params = op.parameters || [];
const params = (op.parameters || []).map(resolveParam);
const pathParams = params.filter((p) => p.in === "path");
const queryParams = params.filter((p) => p.in === "query");
const hasBody = !!op.requestBody;

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
function usage() {
console.error(
"Usage: node scripts/dev/generate-adobe-firefly-snapshot.mjs <discovery.json> <output.ts>"
);
process.exit(2);
}
const [, , inputArg, outputArg] = process.argv;
if (!inputArg || !outputArg) usage();
const inputPath = path.resolve(inputArg);
const outputPath = path.resolve(outputArg);
const inputBytes = fs.readFileSync(inputPath);
const sourceHash = createHash("sha256").update(inputBytes).digest("hex");
const root = JSON.parse(inputBytes.toString("utf8"));
function mergeObjectSchema(schema) {
const merged = { properties: {}, required: [] };
const visit = (node) => {
if (!node || typeof node !== "object") return;
if (node.properties && typeof node.properties === "object") {
Object.assign(merged.properties, node.properties);
}
if (Array.isArray(node.required)) merged.required.push(...node.required);
if (Array.isArray(node.allOf)) node.allOf.forEach(visit);
};
visit(schema);
merged.required = [...new Set(merged.required)];
return merged;
}
function branches(schema) {
if (!schema || typeof schema !== "object") return [];
return [schema, ...(schema.anyOf || []), ...(schema.oneOf || [])];
}
function stringEnums(schema) {
return [
...new Set(
branches(schema)
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
.filter((value) => typeof value === "string")
),
];
}
function integerSchema(schema) {
return branches(schema).find((branch) => branch.type === "integer") || {};
}
function publicModelId(modelId, modelVersion) {
const slug = (value, allowDot = false) =>
String(value || "")
.trim()
.toLowerCase()
.replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const family = slug(modelId);
const publicVersion =
family === "kling" ? String(modelVersion).replace(/^kling_v3_omni/i, "kling_o3") : modelVersion;
const version = slug(publicVersion, true);
if (!version || version === "default" || version === family) return family || "model";
return `${family}-${version}`;
}
function normalizeModel(family, modelVersion, version) {
const schema = mergeObjectSchema(version.requestSchema);
const properties = schema.properties;
const referenceSchema = properties.referenceBlobs || {};
const referenceInputs = [];
for (const media of referenceSchema["x-capabilities"] || []) {
for (const usage of media.usageConstraints || []) {
if (usage.deprecated === true) continue;
referenceInputs.push({
mediaType: String(media.mediaType || ""),
usageType: String(usage.usageType || ""),
minItems: Number.isInteger(usage.minItems) ? usage.minItems : 0,
maxItems: Number.isInteger(usage.maxItems) ? usage.maxItems : null,
maxFileSizeBytes: Number.isInteger(media.maxFileSizeBytes) ? media.maxFileSizeBytes : null,
});
}
}
const supportedSizes = [
...new Set(
branches(properties.size)
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
.filter(
(size) =>
size &&
Number.isInteger(size.width) &&
size.width > 0 &&
Number.isInteger(size.height) &&
size.height > 0
)
.map((size) => `${size.width}x${size.height}`)
),
];
const supportedAspectRatios = [
...new Set(
branches(properties.generationSettings).flatMap((branch) =>
stringEnums(branch?.properties?.aspectRatio)
)
),
];
const duration = integerSchema(properties.duration);
const supportedDurations = [
...new Set(
branches(properties.duration)
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
.filter(Number.isInteger)
),
];
const prompt = branches(properties.prompt).find((branch) => branch.type === "string") || {};
const outputCount = integerSchema(properties.n);
return {
id: publicModelId(family.modelId, modelVersion),
name: String(version.modelDisplayName || version.modelCaiDisplayName || modelVersion),
modality: version.outputModality[0],
upstreamModelId: family.modelId,
upstreamModelVersion: modelVersion,
providerName: String(family.acModelFamilyProviderDisplayName || ""),
releaseReadiness: String(version.releaseReadiness || ""),
healthStatus: String(version.healthStatus || ""),
inputMediaUseCases: (version.inputMediaUseCase || []).map(String),
schemaProperties: Object.keys(properties),
requiredProperties: schema.required,
referenceInputs,
maxReferenceItems: Number.isInteger(referenceSchema.maxItems) ? referenceSchema.maxItems : null,
supportedSizes,
supportedAspectRatios,
supportedResolutions: stringEnums(properties.resolution),
supportedDurations,
durationMin: Number.isInteger(duration.minimum) ? duration.minimum : null,
durationMax: Number.isInteger(duration.maximum) ? duration.maximum : null,
durationDefault: Number.isInteger(duration.default) ? duration.default : null,
outputCountMin: Number.isInteger(outputCount.minimum) ? outputCount.minimum : null,
outputCountMax: Number.isInteger(outputCount.maximum) ? outputCount.maximum : null,
promptMaxLength: Number.isInteger(prompt.maxLength) ? prompt.maxLength : null,
backingModel: String(version.bksGenerationModel || ""),
};
}
const rawModels = [];
for (const family of Array.isArray(root.models) ? root.models : []) {
for (const [modelVersion, version] of Object.entries(family.modelVersions || {})) {
if (!version || version.enabled === false) continue;
const modality = Array.isArray(version.outputModality)
? version.outputModality.map((value) => String(value).toLowerCase())[0]
: "";
if (modality !== "image" && modality !== "video") continue;
const schema = mergeObjectSchema(version.requestSchema);
if (!schema.properties.prompt) continue;
const useCases = (version.inputMediaUseCase || []).map((value) => String(value).toLowerCase());
if (useCases.some((value) => ["upscaling", "sharpening", "denoising"].includes(value))) {
continue;
}
rawModels.push(normalizeModel(family, modelVersion, version));
}
}
// Discovery currently repeats a few exact aliases (for example flux/fluxPro and
// fluxPro/1.1). Keep the first canonical wire pair and suppress duplicate cards.
const seen = new Set();
const models = [];
for (const model of rawModels) {
const semanticKey = JSON.stringify({
backingModel: model.backingModel,
name: model.name,
modality: model.modality,
schemaProperties: model.schemaProperties,
requiredProperties: model.requiredProperties,
referenceInputs: model.referenceInputs,
maxReferenceItems: model.maxReferenceItems,
supportedSizes: model.supportedSizes,
supportedAspectRatios: model.supportedAspectRatios,
supportedResolutions: model.supportedResolutions,
supportedDurations: model.supportedDurations,
durationMin: model.durationMin,
durationMax: model.durationMax,
});
if (seen.has(semanticKey)) continue;
seen.add(semanticKey);
models.push(model);
}
const source = `/**
* Generated from Adobe Firefly POST /v2/models/discovery with resolveSchema=true.
* Source SHA-256: ${sourceHash}
* Regenerate with scripts/dev/generate-adobe-firefly-snapshot.mjs; do not edit by hand.
* The generated literal stays compact to satisfy the repository's line-count gate.
*/
// prettier-ignore
export const ADOBE_FIREFLY_DISCOVERY_SNAPSHOT = ${JSON.stringify(models)} as const;
`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, source, "utf8");
console.log(`Wrote ${models.length} models to ${outputPath}`);

View File

@@ -2,9 +2,21 @@
/**
* Docker healthcheck script for OmniRoute.
* Probes the /api/monitoring/health endpoint on the dashboard port.
* Probes the lightweight /healthz endpoint on the dashboard port.
* /api/monitoring/health is the deep human/dashboard check (SQLite ping);
* using it as Docker HEALTHCHECK marks the container Unhealthy whenever the
* event loop is busy (#10052) and can restart the only replica mid-session.
* Used by Dockerfile and docker-compose files.
*
* #10311 — the container HEALTHCHECK previously probed the heavy
* /api/monitoring/health path (synchronous SQLite reads + deep monitoring
* aggregation) on the same single-process event loop as catalog rebuild /
* long-context compression. Under load that probe could stall past the 5s
* timeout and flip the container `unhealthy`, restarting it mid-session and
* killing active SSE streams. /healthz is a pure in-memory lifecycle check
* with no DB access. Operators who want the deep monitoring probe can opt
* back in with OMNIROUTE_HEALTHCHECK_PATH.
*
* #3151 — in some Docker network setups the server binds to a container IP and
* a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice
* versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every
@@ -21,7 +33,7 @@ import { networkInterfaces } from "node:os";
const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"];
const DEFAULT_TIMEOUT_MS = 4000;
const DEFAULT_HEALTH_PATH = "/api/monitoring/health";
const DEFAULT_HEALTH_PATH = "/healthz";
function normalizeBasePath(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
@@ -32,10 +44,34 @@ function normalizeBasePath(value) {
return `/${segments.join("/")}`;
}
/** Prefixes the health route with the configured Next.js basePath. */
export function resolveHealthPath(basePathValue) {
/**
* Normalize an explicit health-check path override (OMNIROUTE_HEALTHCHECK_PATH).
* Returns "" when absent/invalid so callers fall back to DEFAULT_HEALTH_PATH.
* Mirrors normalizeBasePath's safety rules (no query/hash/backslash, no "." /
* ".." segments, must start with "/").
*/
function normalizeHealthPath(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return "";
if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return "";
const segments = trimmed.split("/").filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) return "";
return `/${segments.join("/")}`;
}
/**
* Resolve the health route to probe. By default the lightweight /healthz
* lifecycle endpoint (pure in-memory, no DB reads). An explicit
* OMNIROUTE_HEALTHCHECK_PATH override opts back into the deep monitoring
* probe. The configured Next.js basePath is always prefixed.
*
* @param {string} [basePathValue] value of OMNIROUTE_BASE_PATH
* @param {string} [healthPathValue] value of OMNIROUTE_HEALTHCHECK_PATH
*/
export function resolveHealthPath(basePathValue, healthPathValue) {
const basePath = normalizeBasePath(basePathValue);
return basePath ? `${basePath}${DEFAULT_HEALTH_PATH}` : DEFAULT_HEALTH_PATH;
const healthPath = normalizeHealthPath(healthPathValue) || DEFAULT_HEALTH_PATH;
return basePath ? `${basePath}${healthPath}` : healthPath;
}
/**
@@ -115,7 +151,10 @@ async function main() {
}
try {
const healthPath = resolveHealthPath(process.env.OMNIROUTE_BASE_PATH);
const healthPath = resolveHealthPath(
process.env.OMNIROUTE_BASE_PATH,
process.env.OMNIROUTE_HEALTHCHECK_PATH
);
await probeHealth({ port, hosts, healthPath });
process.exit(0);
} catch (err) {

View File

@@ -317,6 +317,17 @@ function getAuthHeaders(requestUrl, requestHeaders) {
if (isText(requestHeaders["x-forwarded-for"])) {
headers["x-forwarded-for"] = requestHeaders["x-forwarded-for"];
}
for (const key of [
"session-id",
"session_id",
"x-codex-installation-id",
"x-codex-window-id",
"x-codex-turn-metadata",
"originator",
"user-agent",
]) {
if (isText(requestHeaders[key])) headers[key] = requestHeaders[key];
}
return headers;
}
@@ -585,12 +596,18 @@ class ResponsesWsSession {
// preparedContext, but never touches this.upstream/this.upstreamReady; the caller decides
// whether a new upstream socket is needed.
async runPrepare(message, responseBody) {
const prepared = await callInternal(this.fetchImpl, this.baseUrl, this.bridgeSecret, "prepare", {
requestUrl: this.requestUrl,
headers: getAuthHeaders(this.requestUrl, this.requestHeaders),
message,
response: responseBody,
});
const prepared = await callInternal(
this.fetchImpl,
this.baseUrl,
this.bridgeSecret,
"prepare",
{
requestUrl: this.requestUrl,
headers: getAuthHeaders(this.requestUrl, this.requestHeaders),
message,
response: responseBody,
}
);
if (!prepared.ok) {
const message2 =
@@ -602,6 +619,7 @@ class ResponsesWsSession {
const error = new Error(message2);
error.code = code;
error.status = prepared.status;
if (code === "responses_websocket_http_fallback") error.httpFallback = true;
throw error;
}
@@ -716,11 +734,28 @@ class ResponsesWsSession {
// otherwise every turn after the first bypasses the whole pipeline. This reuses
// the already-established upstream transport; it must NOT recreate the socket.
const prepared = await this.runPrepare(message, nextTurnBody);
this.upstream.send(jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response)));
this.upstream.send(
jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response))
);
return;
}
this.upstream.send(jsonStringifySafe(message));
} catch (error) {
if (error?.httpFallback) {
const failurePayload = this.sendFailure(
"responses_websocket_http_fallback",
"Retry this request over HTTP/SSE Responses"
);
void this.persistHistory({
status: 426,
success: false,
errorCode: "responses_websocket_http_fallback",
errorMessage: "HTTP/SSE Responses transport required",
terminalMessage: failurePayload,
});
this.close(1013, "http_fallback_required");
return;
}
const code = error?.code || "upstream_websocket_connect_failed";
const messageText = error instanceof Error ? error.message : String(error);
const failurePayload = this.sendFailure(code, messageText);

View File

@@ -74,7 +74,15 @@ async function main() {
const vitestProcess = spawn(
process.execPath,
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
[
"./node_modules/vitest/vitest.mjs",
"run",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found".
"--config",
"vitest.e2e-live.config.ts",
"tests/e2e/ecosystem.test.ts",
],
{
stdio: "inherit",
env: testEnv,

View File

@@ -15,6 +15,7 @@ import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs";
import { randomUUID } from "node:crypto";
import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs";
import { createSystemdNotifier } from "./systemd-notify.mjs";
const { maybeHandleDisallowedMethod } = methodGuard;
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
@@ -60,6 +61,13 @@ for (const [key, value] of Object.entries(mergedEnv)) {
}
}
// systemd sd_notify (Type=notify / WatchdogSec=): this process owns the
// watchdog pings — if its event loop blocks (freeze), the pings stop and
// systemd kills the service. No-op outside systemd (no NOTIFY_SOCKET).
// Created AFTER .env is merged so the OMNIROUTE_DISABLE_SD_NOTIFY opt-out
// documented in .env is honored on this path too.
const systemdNotifier = createSystemdNotifier();
// The mergedEnv copy above pulls NODE_ENV straight from `.env` — and the shipped
// `.env.example` default is `NODE_ENV=production`. Next's programmatic `next()`
// entry (unlike the `next` CLI) trusts that value verbatim, so `npm run dev`
@@ -75,8 +83,10 @@ const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
// Turbopack by default in dev (matches the Next 16 CLI default and the production
// build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the
// webpack escape hatch.
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
// webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable,
// so Bun automatically disables Turbopack and uses Webpack.
const isBun = Boolean(process.versions.bun);
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0" && !isBun;
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs.
@@ -184,6 +194,7 @@ async function start() {
});
const shutdown = async (signal) => {
systemdNotifier.stopping();
try {
await new Promise((resolve) => server.close(resolve));
await nextApp.close();
@@ -202,6 +213,8 @@ async function start() {
console.log(
`[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})`
);
systemdNotifier.ready();
systemdNotifier.startWatchdog();
});
}

View File

@@ -73,8 +73,11 @@ async function main() {
[
"./node_modules/vitest/vitest.mjs",
"run",
"--environment",
"node",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found". The config also
// sets environment: node, so the flag is no longer needed here.
"--config",
"vitest.e2e-live.config.ts",
"tests/e2e/protocol-clients.test.ts",
],
{

View File

@@ -5,6 +5,8 @@ import {
resolveRuntimePorts,
withRuntimePortEnv,
resolveMaxOldSpaceMb,
warnConflictingHeapLimits,
buildStandaloneNodeOptions,
spawnWithForwardedSignals,
} from "../build/runtime-env.mjs";
import { bootstrapEnv } from "../build/bootstrap-env.mjs";
@@ -13,13 +15,13 @@ const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
const childEnv = withRuntimePortEnv(env, runtimePorts);
// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob
// `omniroute serve` uses, so Docker users can control the server heap under
// load / large SQLite DBs. A trailing --max-old-space-size wins, so this
// overrides the image fallback without clobbering any other NODE_OPTIONS flags.
// #2939 / #10353: OMNIROUTE_MEMORY_MB is the Docker/standalone heap knob.
// When it is set, we append --max-old-space-size last (V8 last-flag wins).
// When it is unset and NODE_OPTIONS already pins the heap, keep NODE_OPTIONS
// (#5238). Warn when both are set and the numbers disagree.
const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB);
childEnv.NODE_OPTIONS =
`${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim();
warnConflictingHeapLimits(childEnv, maxOldSpaceMb);
childEnv.NODE_OPTIONS = buildStandaloneNodeOptions(childEnv, maxOldSpaceMb);
// Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone
// server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs)

View File

@@ -255,20 +255,33 @@ async function signalProcessTree(child, signal) {
}
}
async function stopApp(child) {
export async function stopApp(
child,
{
currentPlatform = platform(),
signalProcessTreeFn = signalProcessTree,
waitForProcessTreeExitFn = waitForProcessTreeExit,
} = {}
) {
if (!child.pid) return;
await signalProcessTree(child, "SIGTERM");
await waitForProcessTreeExit(child, 5_000);
// On Windows, terminating only the direct Electron process can orphan the
// packaged server when the parent exits before the follow-up liveness check.
// Kill the process tree in one operation while the root PID is still valid.
if (currentPlatform === "win32") {
await signalProcessTreeFn(child, "SIGKILL");
await waitForProcessTreeExitFn(child, 2_000);
return;
}
const isStillRunning =
platform() === "win32"
? child.exitCode === null && child.signalCode === null
: isProcessGroupAlive(child.pid);
await signalProcessTreeFn(child, "SIGTERM");
await waitForProcessTreeExitFn(child, 5_000);
const isStillRunning = isProcessGroupAlive(child.pid);
if (isStillRunning) {
await signalProcessTree(child, "SIGKILL");
await waitForProcessTreeExit(child, 2_000);
await signalProcessTreeFn(child, "SIGKILL");
await waitForProcessTreeExitFn(child, 2_000);
}
}
@@ -396,45 +409,115 @@ async function settleAfterReady({ getExitState, logs, settleMs }) {
}
}
async function main() {
const appExecutable = discoverPackagedExecutable();
if (!existsSync(appExecutable)) {
function assertExecutableExists(appExecutable) {
if (existsSync(appExecutable)) return;
throw new Error(
`Packaged OmniRoute executable not found at ${appExecutable}. Build it first with \`npm run build:<target> --prefix electron\` or set ELECTRON_SMOKE_APP_EXECUTABLE.`
);
}
// ── CI sandbox workaround ──────────────────────────────────
// GitHub Actions runners cannot set SUID on chrome-sandbox (Linux)
// and Windows runners may fail silently without --no-sandbox.
function buildCiSpawnArgs(currentPlatform = platform()) {
if (!process.env.CI) return [];
const spawnArgs = ["--no-sandbox", "--disable-gpu"];
if (currentPlatform === "linux") {
spawnArgs.push("--disable-dev-shm-usage");
}
return spawnArgs;
}
const NATIVE_DRIVER_LOG_PATTERN = /\[DB\] Driver: (bun:sqlite|better-sqlite3|node:sqlite) \|/;
const SQLJS_DRIVER_LOG_PATTERN = /\[DB\] Driver: sql\.js \|/;
/**
* Regression guard for #7592: on a packaged app's SECOND launch against an
* already-persisted DATA_DIR, a stale-ABI better-sqlite3 binary (resolved via
* a Turbopack-hashed import) used to fail to load and silently fall through
* to the sql.js (WASM) driver — which then OOMs/retry-loops on real-sized
* databases. Asserts the startup log shows a native driver was selected.
*/
export function assertNativeDriverSelected(logs) {
if (NATIVE_DRIVER_LOG_PATTERN.test(logs)) return;
if (SQLJS_DRIVER_LOG_PATTERN.test(logs)) {
throw new Error(
`Packaged OmniRoute executable not found at ${appExecutable}. Build it first with \`npm run build:<target> --prefix electron\` or set ELECTRON_SMOKE_APP_EXECUTABLE.`
"Packaged Electron app fell back to the sql.js (WASM) driver instead of a native SQLite " +
"driver — this is the regression #7592 guards against (stale-ABI better-sqlite3 binary)."
);
}
const smokeUrl = process.env.ELECTRON_SMOKE_URL || DEFAULT_URL;
const timeoutMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const settleMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_SETTLE_MS, DEFAULT_SETTLE_MS);
const dataDir =
process.env.ELECTRON_SMOKE_DATA_DIR ||
(await mkdtemp(join(tmpdir(), "omniroute-electron-smoke-")));
const removeDataDir =
!process.env.ELECTRON_SMOKE_DATA_DIR && process.env.ELECTRON_SMOKE_KEEP_DATA !== "1";
const smokeEnv = buildSmokeEnv({ dataDir });
throw new Error(
"Packaged Electron app logs contain no '[DB] Driver: ...' line — cannot confirm which SQLite " +
"driver loaded."
);
}
async function waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState }) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < timeoutMs) {
assertNoFatalLogs(logs.value);
if (exitState.spawnError !== null) {
throw new Error(`Packaged Electron app failed to launch: ${exitState.spawnError.message}`);
}
if (exitState.exitCode !== null || exitState.signalCode !== null) {
throw new Error(
`Packaged Electron app exited before readiness: code=${exitState.exitCode} signal=${exitState.signalCode}`
);
}
try {
const response = await fetchWithTimeout(smokeUrl, 1_000);
if (response.status === 200) {
assertNoFatalLogs(logs.value);
console.log(`[electron-smoke] ready: ${smokeUrl} returned HTTP 200`);
await settleAfterReady({
getExitState: () => ({ exitCode: exitState.exitCode, signalCode: exitState.signalCode }),
logs,
settleMs,
});
console.log(`[electron-smoke] stable for ${settleMs}ms after readiness`);
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await sleep(500);
}
throw new Error(
`Packaged Electron app did not serve ${smokeUrl} within ${timeoutMs}ms. Last error: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
);
}
/**
* Launches the packaged app once against `dataDir`, waits for readiness +
* settle, tears it down, and returns the captured stdout/stderr text. Shared
* by the single-launch path and the cold-restart (two-launch) path so both
* exercise identical spawn/readiness/shutdown behavior.
*/
async function launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs }) {
const smokeEnv = buildSmokeEnv({ dataDir });
await assertPortIsFree(smokeUrl);
await ensureSmokeEnvDirs(smokeEnv, dataDir);
// ── CI sandbox workaround ──────────────────────────────────
// GitHub Actions runners cannot set SUID on chrome-sandbox (Linux)
// and Windows runners may fail silently without --no-sandbox.
const spawnArgs = [];
if (process.env.CI) {
spawnArgs.push("--no-sandbox", "--disable-gpu");
if (platform() === "linux") {
spawnArgs.push("--disable-dev-shm-usage");
}
}
const spawnArgs = buildCiSpawnArgs();
console.log(`[electron-smoke] launching ${appExecutable}`);
if (spawnArgs.length) console.log(`[electron-smoke] CI args: ${spawnArgs.join(" ")}`);
console.log(`[electron-smoke] DATA_DIR=${dataDir}`);
console.log(`[electron-smoke] waiting for ${smokeUrl}`);
const logs = { value: "" };
const streamLogs = process.env.ELECTRON_SMOKE_STREAM_LOGS === "1";
const child = spawn(appExecutable, spawnArgs, {
detached: platform() !== "win32",
env: smokeEnv,
@@ -444,60 +527,18 @@ async function main() {
child.stdout?.on("data", (chunk) => appendLog(logs, chunk, "[electron] ", streamLogs));
child.stderr?.on("data", (chunk) => appendLog(logs, chunk, "[electron:err] ", streamLogs));
let exitCode = null;
let signalCode = null;
let spawnError = null;
const exitState = { exitCode: null, signalCode: null, spawnError: null };
child.once("exit", (code, signal) => {
exitCode = code;
signalCode = signal;
exitState.exitCode = code;
exitState.signalCode = signal;
});
child.once("error", (error) => {
spawnError = error;
exitState.spawnError = error;
});
try {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < timeoutMs) {
assertNoFatalLogs(logs.value);
if (spawnError !== null) {
throw new Error(`Packaged Electron app failed to launch: ${spawnError.message}`);
}
if (exitCode !== null || signalCode !== null) {
throw new Error(
`Packaged Electron app exited before readiness: code=${exitCode} signal=${signalCode}`
);
}
try {
const response = await fetchWithTimeout(smokeUrl, 1_000);
if (response.status === 200) {
assertNoFatalLogs(logs.value);
console.log(`[electron-smoke] ready: ${smokeUrl} returned HTTP 200`);
await settleAfterReady({
getExitState: () => ({ exitCode, signalCode }),
logs,
settleMs,
});
console.log(`[electron-smoke] stable for ${settleMs}ms after readiness`);
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Packaged Electron app did not serve ${smokeUrl} within ${timeoutMs}ms. Last error: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
);
await waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState });
return logs.value;
} catch (error) {
if (!streamLogs) {
printLogTail(logs.value);
@@ -506,6 +547,43 @@ async function main() {
} finally {
await stopApp(child);
await waitForPortClosed(smokeUrl);
}
}
async function main() {
const appExecutable = discoverPackagedExecutable();
assertExecutableExists(appExecutable);
const smokeUrl = process.env.ELECTRON_SMOKE_URL || DEFAULT_URL;
const timeoutMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const settleMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_SETTLE_MS, DEFAULT_SETTLE_MS);
const streamLogs = process.env.ELECTRON_SMOKE_STREAM_LOGS === "1";
// #7592: rerun against the SAME (persisted) DATA_DIR and assert the second
// launch selected a native SQLite driver, not the sql.js WASM fallback.
const coldRestart = process.env.ELECTRON_SMOKE_COLD_RESTART === "1";
const dataDir =
process.env.ELECTRON_SMOKE_DATA_DIR ||
(await mkdtemp(join(tmpdir(), "omniroute-electron-smoke-")));
const removeDataDir =
!process.env.ELECTRON_SMOKE_DATA_DIR && process.env.ELECTRON_SMOKE_KEEP_DATA !== "1";
try {
await launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs });
if (!coldRestart) return;
console.log("[electron-smoke] cold-restart: relaunching against the same DATA_DIR");
const secondLaunchLogs = await launchAndCollectLogs({
appExecutable,
smokeUrl,
dataDir,
timeoutMs,
settleMs,
streamLogs,
});
assertNativeDriverSelected(secondLaunchLogs);
console.log("[electron-smoke] cold-restart: native SQLite driver confirmed on second launch");
} finally {
if (removeDataDir) {
await rm(dataDir, { recursive: true, force: true });
}

View File

@@ -3,11 +3,25 @@ import net from "node:net";
import { randomUUID } from "node:crypto";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
import { maybeHandleWebdav } from "./webdav-handler.mjs";
import { maybeHandleWebdav, WEBDAV_PREFIX } from "./webdav-handler.mjs";
import methodGuard from "./http-method-guard.cjs";
import headResponseGuard from "./head-response-guard.cjs";
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs";
import { createSystemdNotifier } from "./systemd-notify.mjs";
// systemd sd_notify (Type=notify / WatchdogSec=): this process is the one
// whose event loop can freeze (cold /v1/models rebuild), so it must own the
// watchdog pings — a blocked loop stops the pings and systemd kills the
// service. No-op outside systemd (no NOTIFY_SOCKET).
const systemdNotifier = createSystemdNotifier();
let systemdReadySent = false;
// NOTE: if an operator sets NEXT_MANUAL_SIG_HANDLE=1, Next never registers its
// own signal cleanup and these once() handlers would suppress Node's default
// signal exit (process lingers until systemd's stop-timeout SIGKILL). Nothing
// in this repo sets that var; acceptable, documented behavior.
process.once("SIGINT", () => systemdNotifier.stopping());
process.once("SIGTERM", () => systemdNotifier.stopping());
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
@@ -122,14 +136,20 @@ function wrapUpgradeListener(server, listener) {
* Returns true if the request was handled; the wrapped listener is never called.
*/
function wrapRequestListenerWithWebdav(listener) {
return async function webdavAwareRequestHandler(req, res) {
try {
const handled = await maybeHandleWebdav(req, res);
if (handled) return;
} catch {
// Never block a request on WebDAV errors — fall through to Next
return function webdavAwareRequestHandler(req, res) {
if (!(req.url || "").startsWith(WEBDAV_PREFIX)) {
return listener.call(this, req, res);
}
return listener.call(this, req, res);
const self = this;
(async () => {
try {
const handled = await maybeHandleWebdav(req, res);
if (handled) return;
} catch {
// Never block a request on WebDAV errors — fall through to Next
}
return listener.call(self, req, res);
})();
};
}
@@ -203,6 +223,15 @@ http.createServer = function createServerWithResponsesWs(...args) {
return originalAddListener(eventName, listener);
};
// sd_notify READY once the main listener is actually accepting, then arm
// the watchdog keep-alive interval (unref'd — never keeps the process up).
server.once("listening", () => {
if (systemdReadySent) return;
systemdReadySent = true;
systemdNotifier.ready();
systemdNotifier.startWatchdog();
});
return server;
};

View File

@@ -0,0 +1,98 @@
/**
* Minimal systemd sd_notify integration (sd_notify(3) protocol).
*
* Node's stable API has no AF_UNIX datagram socket support (node:dgram is
* udp4/udp6 only), so notifications are sent by spawning the `systemd-notify`
* binary — present on every systemd host, no extra dependency.
*
* Everything is guarded: without a NOTIFY_SOCKET (plain terminal, Docker,
* Electron, Windows) the notifier is a no-op and costs nothing. Set
* OMNIROUTE_DISABLE_SD_NOTIFY=1 to force-disable even under systemd.
*
* A watchdog keep-alive interval lives in the main event loop of the process
* that runs it: if that loop is ever blocked (frozen server, cf. the cold
* /v1/models rebuild freeze), the pings stop and systemd kills the service
* after WatchdogSec=.
*/
import { spawn } from "node:child_process";
export const SD_NOTIFY_BINARY = "systemd-notify";
export const SD_NOTIFY_SOCKET_ENV = "NOTIFY_SOCKET";
export const SD_NOTIFY_DISABLE_ENV = "OMNIROUTE_DISABLE_SD_NOTIFY";
// Ping every 60s — satisfies any systemd WatchdogSec= >= 120s (systemd
// requires keep-alive pings at most every WatchdogSec/2).
export const SD_NOTIFY_WATCHDOG_INTERVAL_MS = 60_000;
export function isSystemdNotifyEnabled(env = process.env) {
return Boolean(env[SD_NOTIFY_SOCKET_ENV]) && env[SD_NOTIFY_DISABLE_ENV] !== "1";
}
export function buildNotifyMessage(kind) {
switch (kind) {
case "ready":
return "READY=1";
case "watchdog":
return "WATCHDOG=1";
case "stopping":
return "STOPPING=1";
default:
throw new Error(`[omniroute][sd_notify] unknown message kind: ${kind}`);
}
}
export function createSystemdNotifier({
env = process.env,
binary = SD_NOTIFY_BINARY,
watchdogIntervalMs = SD_NOTIFY_WATCHDOG_INTERVAL_MS,
spawnFn = spawn,
onWarn = (message) => console.warn(message),
} = {}) {
const enabled = isSystemdNotifyEnabled(env);
let disabled = false;
let watchdogTimer = null;
const send = (kind) => {
if (!enabled || disabled) return;
const child = spawnFn(binary, [buildNotifyMessage(kind)], { env, stdio: "ignore" });
// Never let a hung systemd-notify keep the process alive.
child.unref?.();
child.on("error", (err) => {
// A failed send means systemd never sees the keep-alive: the service
// would be killed as unhealthy anyway, so disabling loudly (one
// warning) is safer than spamming errors forever.
disabled = true;
if (watchdogTimer) {
clearInterval(watchdogTimer);
watchdogTimer = null;
}
onWarn(
`[omniroute][sd_notify] failed to send '${kind}' (${err?.code ?? err?.message ?? err}); sd_notify disabled for this process`
);
});
};
return {
enabled,
ready() {
send("ready");
},
watchdog() {
send("watchdog");
},
stopping() {
send("stopping");
},
startWatchdog() {
if (!enabled || disabled || watchdogTimer) return;
watchdogTimer = setInterval(() => send("watchdog"), watchdogIntervalMs);
watchdogTimer.unref?.();
},
dispose() {
if (watchdogTimer) {
clearInterval(watchdogTimer);
watchdogTimer = null;
}
},
};
}

View File

@@ -185,6 +185,18 @@ function getForwardHeaders(requestUrl, requestHeaders) {
headers.origin = origin;
}
for (const key of [
"session-id",
"session_id",
"x-codex-installation-id",
"x-codex-window-id",
"x-codex-turn-metadata",
"originator",
"user-agent",
]) {
if (isText(requestHeaders[key])) headers[key] = requestHeaders[key];
}
return headers;
}

5
scripts/devin-bridge/build Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
bridge_prepare_sandbox
docker compose -f "$BRIDGE_COMPOSE" --profile offline build

12
scripts/devin-bridge/clean Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
if [[ "${1:-}" == "--all" ]]; then
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down \
--remove-orphans --volumes
printf 'Containers, networks, and bridge-owned named volumes were removed.\n'
else
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down \
--remove-orphans
printf 'Containers and networks stopped. Named auth/config volumes were preserved; use --all to remove them.\n'
fi

131
scripts/devin-bridge/common Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
BRIDGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BRIDGE_COMPOSE="$BRIDGE_ROOT/docker/devin-bridge/compose.yml"
BRIDGE_SANDBOX="$BRIDGE_ROOT/.sandbox"
BRIDGE_GUARD_AUDIT_ROOT="$BRIDGE_SANDBOX/guard-audit"
BRIDGE_CLAUDE_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/claude/egress.jsonl"
BRIDGE_DEVIN_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/devin/egress.jsonl"
BRIDGE_RUNTIME_POLICY="$BRIDGE_ROOT/scripts/devin-bridge/runtime-policy.mjs"
bridge_prepare_sandbox() {
mkdir -p "$BRIDGE_SANDBOX/home" "$BRIDGE_SANDBOX/test-data" \
"$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \
"$BRIDGE_SANDBOX/evidence" "$BRIDGE_GUARD_AUDIT_ROOT/claude" \
"$BRIDGE_GUARD_AUDIT_ROOT/devin"
chmod 0777 "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \
"$BRIDGE_SANDBOX/evidence"
chmod 01777 "$BRIDGE_GUARD_AUDIT_ROOT/claude" "$BRIDGE_GUARD_AUDIT_ROOT/devin"
}
bridge_reset_guard_audit() {
local audit_path="$1"
local audit_dir
local temp_path
bridge_prepare_sandbox
audit_dir="$(dirname "$audit_path")"
temp_path="$(mktemp "$audit_dir/.egress.jsonl.XXXXXX")"
chmod 0666 "$temp_path"
mv -f "$temp_path" "$audit_path"
}
bridge_reset_claude_egress_audit() {
bridge_reset_guard_audit "$BRIDGE_CLAUDE_AUDIT"
}
bridge_reset_devin_egress_audit() {
bridge_reset_guard_audit "$BRIDGE_DEVIN_AUDIT"
}
bridge_reset_e2e_fixture() {
bridge_prepare_sandbox
cp -R "$BRIDGE_ROOT/tests/fixtures/devin-bridge/e2e-workspace/." \
"$BRIDGE_SANDBOX/e2e-workspace/"
rm -f "$BRIDGE_SANDBOX/e2e-workspace/.e2e-hook.log" \
"$BRIDGE_SANDBOX/evidence/claude-stream.jsonl" \
"$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
bridge_reset_claude_egress_audit
}
bridge_reset_live_fixture() {
bridge_prepare_sandbox
cp -R "$BRIDGE_ROOT/tests/fixtures/devin-bridge/e2e-workspace/." \
"$BRIDGE_SANDBOX/live-workspace/"
rm -f "$BRIDGE_SANDBOX/live-workspace/.e2e-hook.log" \
"$BRIDGE_SANDBOX/evidence/live-analysis.jsonl" \
"$BRIDGE_SANDBOX/evidence/live-fix.jsonl" \
"$BRIDGE_SANDBOX/evidence/live-command.jsonl" \
"$BRIDGE_SANDBOX/evidence/live-models.json" \
"$BRIDGE_SANDBOX/evidence/egress.jsonl"
bridge_reset_claude_egress_audit
bridge_reset_devin_egress_audit
}
bridge_test_env() {
bridge_prepare_sandbox
env HOME="$BRIDGE_SANDBOX/home" DATA_DIR="$BRIDGE_SANDBOX/test-data" SQLITE_FILE="$BRIDGE_SANDBOX/test-data/storage.sqlite" DEVIN_AGENTIC_HOME="$BRIDGE_SANDBOX/home" "$@"
}
bridge_run_devin() {
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \
omniroute-live sh -ceu '
trusted_proxy=http://network-guard:8080
test "${DEVIN_BRIDGE_PROXY_URL:-}" = "$trusted_proxy"
export HTTP_PROXY="$trusted_proxy" HTTPS_PROXY="$trusted_proxy"
unset ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy
exec devin "$@"
' bridge-devin "$@"
}
bridge_assert_devin_auth_status() {
local exit_status="$1"
local output="$2"
printf '%s' "$output" | node --input-type=module -e '
import { pathToFileURL } from "node:url";
import fs from "node:fs";
const policy = await import(pathToFileURL(process.argv[1]));
const result = policy.validateDevinAuthStatus(process.argv[2], fs.readFileSync(0, "utf8"));
if (!result.ok) throw new Error(result.error);
' "$BRIDGE_RUNTIME_POLICY" "$exit_status"
}
bridge_check_devin_auth() {
local output
local exit_status
set +e
output="$(bridge_run_devin auth status 2>&1)"
exit_status=$?
set -e
bridge_assert_devin_auth_status "$exit_status" "$output"
printf 'PASS: Devin authentication confirmed\n'
}
bridge_assert_zero_claude_egress() {
local audit_path="$1"
bridge_validate_guard_audit claude-zero "$audit_path"
}
bridge_assert_claude_guard_denials() {
local audit_path="$1"
bridge_validate_guard_audit claude-denials "$audit_path"
}
bridge_assert_devin_guard_audit() {
local audit_path="$1"
bridge_validate_guard_audit devin-allowed "$audit_path"
}
bridge_validate_guard_audit() {
local kind="$1"
local audit_path="$2"
node --input-type=module -e '
import { pathToFileURL } from "node:url";
const policy = await import(pathToFileURL(process.argv[1]));
policy.validateAuditFile(process.argv[2], process.argv[3], process.argv[4]);
' "$BRIDGE_RUNTIME_POLICY" "$kind" "$audit_path" "$(id -u)"
}
bridge_export_guard_audit() {
local audit_path="$1"
local evidence_name="$2"
cp "$audit_path" "$BRIDGE_SANDBOX/evidence/$evidence_name"
chmod 0644 "$BRIDGE_SANDBOX/evidence/$evidence_name"
}
bridge_cleanup_compose() {
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin \
down --remove-orphans >/dev/null 2>&1 || true
}

28
scripts/devin-bridge/launch Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
trap bridge_cleanup_compose EXIT
bridge_cleanup_compose
bridge_prepare_sandbox
"$(dirname "$0")/verify-anthropic-isolation"
bridge_reset_claude_egress_audit
bridge_reset_devin_egress_audit
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard claude-egress-guard
bridge_check_devin_auth
bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json"
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
<"$BRIDGE_SANDBOX/evidence/live-models.json")"
export DEVIN_BRIDGE_MODEL="devin-cli-agentic/$devin_model"
export DEVIN_BRIDGE_SONNET_MODEL="${DEVIN_BRIDGE_SONNET_MODEL:-$DEVIN_BRIDGE_MODEL}"
export DEVIN_BRIDGE_OPUS_MODEL="${DEVIN_BRIDGE_OPUS_MODEL:-$DEVIN_BRIDGE_MODEL}"
export DEVIN_BRIDGE_HAIKU_MODEL="${DEVIN_BRIDGE_HAIKU_MODEL:-$DEVIN_BRIDGE_MODEL}"
export DEVIN_BRIDGE_SUBAGENT_MODEL="${DEVIN_BRIDGE_SUBAGENT_MODEL:-$DEVIN_BRIDGE_MODEL}"
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait omniroute-live
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \
claude-live claude
bridge_cleanup_compose
bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT"
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
trap - EXIT

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; }
trap bridge_cleanup_compose EXIT
bridge_cleanup_compose
bridge_prepare_sandbox
bridge_reset_devin_egress_audit
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard
bridge_run_devin auth login --force-manual-token-flow
bridge_cleanup_compose
trap - EXIT
exec env ENABLE_LIVE_DEVIN_TESTS=1 "$(dirname "$0")/test-live-devin"

View File

@@ -0,0 +1,111 @@
import fs from "node:fs";
const ALLOWED_DEVIN_SUFFIXES = [".devin.ai", ".cognition.ai"];
const ALLOWED_DEVIN_EXACT = ["server.codeium.com", "unleash.codeium.com"];
function normalizedHostname(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\.$/, "");
}
export function isAllowedDevinAuditHostname(hostname) {
const value = normalizedHostname(hostname);
return (
ALLOWED_DEVIN_EXACT.includes(value) ||
ALLOWED_DEVIN_SUFFIXES.some((suffix) => value === suffix.slice(1) || value.endsWith(suffix))
);
}
export function validateDevinAuthStatus(exitStatus, output) {
if (Number(exitStatus) !== 0) return { ok: false, error: "auth status command failed" };
const lines = String(output)
.split(/\r?\n/)
.map((line) => line.trim());
if (!lines.some((line) => /^Logged in \(via Devin\)\.?$/.test(line))) {
return { ok: false, error: "auth status did not confirm login" };
}
if (lines.some((line) => /failed to fetch from server/i.test(line))) {
return { ok: false, error: "auth status could not confirm server access" };
}
return { ok: true };
}
export function parseAuditEntries(text) {
const lines = String(text)
.split(/\r?\n/)
.filter((line) => line.trim().length > 0);
return lines.map((line) => JSON.parse(line));
}
export function validateZeroClaudeEgress(text) {
if (String(text).length !== 0) {
return { ok: false, error: "Claude attempted external egress during the real run" };
}
return { ok: true };
}
export function validateClaudeGuardDenials(text) {
const entries = parseAuditEntries(text);
if (!entries.length) return { ok: false, error: "Claude egress audit has no records" };
if (entries.some((entry) => entry.decision !== "deny")) {
return { ok: false, error: "Claude egress audit contains a non-deny decision" };
}
for (const hostname of ["api.anthropic.com", "claude.ai"]) {
if (!entries.some((entry) => entry.hostname === hostname && entry.decision === "deny")) {
return { ok: false, error: `Claude egress audit is missing deny for ${hostname}` };
}
}
return { ok: true };
}
export function validateDevinGuardAudit(text) {
const entries = parseAuditEntries(text);
if (!entries.length) return { ok: false, error: "Devin egress audit has no records" };
let sawAllowedDevinRequest = false;
for (const entry of entries) {
if (entry.decision === "deny") {
if (/anthropic|claude\.ai/i.test(normalizedHostname(entry.hostname))) {
return { ok: false, error: `forbidden Devin egress attempt: ${String(entry.hostname)}` };
}
continue;
}
if (entry.decision !== "allow" || !isAllowedDevinAuditHostname(entry.hostname)) {
return { ok: false, error: `unexpected Devin egress record: ${String(entry.hostname)}` };
}
sawAllowedDevinRequest = true;
}
return sawAllowedDevinRequest
? { ok: true }
: { ok: false, error: "Devin egress audit has no approved request" };
}
export function validateAuditFileStat(stat, expectedUid) {
if (!stat || !stat.isFile() || stat.isSymbolicLink()) return "audit path is not a regular file";
if (stat.nlink !== 1) return "audit file link count is not one";
if (stat.uid !== Number(expectedUid)) return "audit file owner mismatch";
if ((stat.mode & 0o777) !== 0o666) return "audit file mode mismatch";
return null;
}
export function readValidatedAuditFile(path, expectedUid) {
const stat = fs.lstatSync(path);
const statError = validateAuditFileStat(stat, expectedUid);
if (statError) throw new Error(statError);
return fs.readFileSync(path, "utf8");
}
export function validateAuditFile(kind, path, expectedUid) {
const text = readValidatedAuditFile(path, expectedUid);
const result =
kind === "claude-zero"
? validateZeroClaudeEgress(text)
: kind === "claude-denials"
? validateClaudeGuardDenials(text)
: kind === "devin-allowed"
? validateDevinGuardAudit(text)
: { ok: false, error: `unknown audit validation kind: ${kind}` };
if (!result.ok) throw new Error(result.error);
return text;
}

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
import fs from "node:fs";
import { pathToFileURL } from "node:url";
import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts";
const candidateFields = new Set([
"model_id",
"modelId",
"model_uid",
"modelUid",
"family_uid",
"familyUid",
]);
function normalizeModelId(value) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function collect(value, candidates) {
if (Array.isArray(value)) {
value.forEach((item) => collect(item, candidates));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, nested] of Object.entries(value)) {
if (
typeof nested === "string" &&
candidateFields.has(key) &&
/^[a-z0-9][a-z0-9._/-]*$/i.test(nested)
) {
candidates.push(nested);
}
collect(nested, candidates);
}
}
export function selectLiveModel(
document,
environment = process.env,
catalog = DEVIN_MODEL_CATALOG
) {
const candidates = [];
collect(document, candidates);
const unique = [...new Set(candidates)];
const normalizedCatalog = new Map();
for (const entry of catalog) {
const normalized = normalizeModelId(entry.id);
const existing = normalizedCatalog.get(normalized) || [];
existing.push(entry.id);
normalizedCatalog.set(normalized, existing);
}
for (const [normalized, ids] of normalizedCatalog) {
if (ids.length > 1) {
throw new Error(
`Ambiguous OmniRoute catalog normalization for ${normalized}: ${ids.join(", ")}`
);
}
}
const catalogIds = new Set(catalog.map((entry) => entry.id));
const available = [
...new Set(
unique
.map((candidate) => normalizeModelId(candidate))
.filter((candidate) => normalizedCatalog.has(candidate))
),
];
for (const [name, configured] of [
["DEVIN_BRIDGE_SONNET_MODEL", environment.DEVIN_BRIDGE_SONNET_MODEL],
["DEVIN_BRIDGE_OPUS_MODEL", environment.DEVIN_BRIDGE_OPUS_MODEL],
["DEVIN_BRIDGE_HAIKU_MODEL", environment.DEVIN_BRIDGE_HAIKU_MODEL],
["DEVIN_BRIDGE_SUBAGENT_MODEL", environment.DEVIN_BRIDGE_SUBAGENT_MODEL],
]) {
if (!configured) continue;
const prefix = "devin-cli-agentic/";
const modelId = configured.startsWith(prefix) ? configured.slice(prefix.length) : "";
if (!modelId || !catalogIds.has(modelId) || !available.includes(modelId)) {
throw new Error(`${name} is not a model returned by Devin and present in OmniRoute`);
}
}
const selected =
available.find((candidate) => candidate === "swe-1-7-lightning") ||
available.find((candidate) => candidate === "swe-1-7") ||
available.find((candidate) => /swe|claude|gpt|gemini/i.test(candidate)) ||
available[0];
if (!selected) {
throw new Error("Devin returned no model identifier present in OmniRoute's Devin catalog");
}
return selected;
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const document = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(selectLiveModel(document));
}

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
bridge_prepare_sandbox
rm -f "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
"$(dirname "$0")/verify-anthropic-isolation" --static
docker compose -f "$BRIDGE_COMPOSE" --profile offline down --remove-orphans
docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-exit \
--exit-code-from contract contract
node -e '
const fs = require("node:fs");
const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map(JSON.parse);
const repairRows = rows.filter((row) => row.scenario === "narrative-repair");
if (
rows.length !== 7 ||
rows.some((row) => row.provider !== "devin-cli-agentic") ||
repairRows.length !== 2 ||
repairRows[0].stage !== "initial" ||
repairRows[1].stage !== "repair"
) {
throw new Error("wire contract observed a missing or non-Devin provider");
}
' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
printf 'PASS: bridge wire contract suite completed without provider fallback\n'

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
trap bridge_cleanup_compose EXIT
bridge_cleanup_compose
bridge_reset_e2e_fixture
"$(dirname "$0")/verify-anthropic-isolation" --static
docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-exit \
--exit-code-from claude claude
grep -q '"action":"final"' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
grep -q 'BRIDGE_E2E_COMPLETE' "$BRIDGE_SANDBOX/evidence/claude-stream.jsonl"
bridge_cleanup_compose
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
trap - EXIT
printf 'PASS: real Claude Code completed the offline agentic fixture\n'

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; }
trap bridge_cleanup_compose EXIT
bridge_cleanup_compose
bridge_reset_live_fixture
"$(dirname "$0")/verify-anthropic-isolation" --static
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard
bridge_check_devin_auth
models_file="$BRIDGE_SANDBOX/evidence/live-models.json"
if [[ -n "${DEVIN_BRIDGE_DISCOVERED_MODEL:-}" ]]; then
devin_model="$DEVIN_BRIDGE_DISCOVERED_MODEL"
else
for attempt in 1 2 3; do
if bridge_run_devin models list --format json >"$models_file"; then
break
fi
[[ "$attempt" == 3 ]] && exit 1
sleep 1
done
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
<"$models_file")"
fi
export DEVIN_BRIDGE_MODEL="devin-cli-agentic/$devin_model"
export DEVIN_BRIDGE_SONNET_MODEL="$DEVIN_BRIDGE_MODEL"
export DEVIN_BRIDGE_OPUS_MODEL="$DEVIN_BRIDGE_MODEL"
export DEVIN_BRIDGE_HAIKU_MODEL="$DEVIN_BRIDGE_MODEL"
export DEVIN_BRIDGE_SUBAGENT_MODEL="$DEVIN_BRIDGE_MODEL"
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up --abort-on-container-exit --exit-code-from claude-live claude-live
bridge_cleanup_compose
bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT"
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
trap - EXIT
printf 'PASS: live model %s was discovered and validated by three scenarios\n' "$devin_model"

9
scripts/devin-bridge/test-unit Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
cd "$BRIDGE_ROOT"
bridge_test_env node --import tsx/esm --test \
tests/unit/executor-devin-cli-agentic-core.test.ts \
tests/unit/executor-devin-cli-agentic-acp.test.ts \
tests/unit/devin-bridge-network-guard.test.ts \
tests/unit/devin-bridge-live-runtime.test.ts

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env node
import fs from "node:fs";
import { pathToFileURL } from "node:url";
function contentBlocks(message) {
return Array.isArray(message?.message?.content) ? message.message.content : [];
}
export function validateClaudeEvidenceText(text, options) {
const marker = String(options?.marker || "").trim();
const requiredTools = Array.isArray(options?.requiredTools) ? options.requiredTools : [];
if (!marker) throw new Error("A final marker is required");
const toolUses = new Map();
const successfulResults = new Set();
const slashCommands = new Set();
const skills = new Set();
let finalResult = null;
for (const [index, rawLine] of String(text).split(/\r?\n/).entries()) {
const line = rawLine.trim();
if (!line) continue;
let event;
try {
event = JSON.parse(line);
} catch {
throw new Error(`Invalid Claude evidence JSON at line ${index + 1}`);
}
if (event?.type === "system" && event?.subtype === "init") {
for (const command of Array.isArray(event.slash_commands) ? event.slash_commands : []) {
slashCommands.add(String(command));
}
for (const skill of Array.isArray(event.skills) ? event.skills : []) {
skills.add(String(skill));
}
}
for (const block of contentBlocks(event)) {
if (block?.type === "tool_use" && typeof block.id === "string") {
toolUses.set(block.id, { name: String(block.name || ""), input: block.input || {} });
}
if (
block?.type === "tool_result" &&
typeof block.tool_use_id === "string" &&
block.is_error !== true
) {
successfulResults.add(block.tool_use_id);
}
}
if (event?.type === "result") finalResult = event;
}
if (!finalResult || finalResult.subtype !== "success" || finalResult.is_error === true) {
throw new Error("Claude evidence has no successful terminal result");
}
const resultText = String(finalResult.result || "");
const incompleteResult = [
/(?:^|\n)\s*(?:\*\*)?blocker(?:\*\*)?\s*:/im,
/\btask (?:is|remains) (?:not complete|incomplete)\b/i,
/(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?next steps? needed(?:\*\*)?\s*:/im,
].some((pattern) => pattern.test(resultText));
if (incompleteResult) {
throw new Error("Claude terminal result explicitly reports incomplete work");
}
if (options?.requiredSlashCommand && !slashCommands.has(options.requiredSlashCommand)) {
throw new Error(`Claude did not load required slash command: ${options.requiredSlashCommand}`);
}
if (options?.requiredSkill && !skills.has(options.requiredSkill)) {
throw new Error(`Claude did not load required skill: ${options.requiredSkill}`);
}
const markerIsStandalone = resultText.split(/\r?\n/).some((line) => line.trim() === marker);
for (const requiredTool of requiredTools) {
if (![...toolUses.values()].some((tool) => tool.name === requiredTool)) {
throw new Error(`Claude did not request required client-owned tool: ${requiredTool}`);
}
}
const npmTestSucceeded = [...toolUses.entries()].some(
([id, tool]) =>
tool.name === "Bash" &&
/\bnpm\s+test\b/.test(String(tool.input?.command || "")) &&
successfulResults.has(id)
);
if (options?.requireSuccessfulNpmTest) {
if (!npmTestSucceeded) throw new Error("Claude evidence has no successful npm test tool turn");
}
const markerIsCorroborated =
options?.requireSuccessfulNpmTest && npmTestSucceeded && resultText.includes(marker);
const explicitCompletionIsCorroborated =
options?.acceptExplicitCompletion === true &&
npmTestSucceeded &&
/\btask is complete\b/i.test(resultText);
if (!markerIsStandalone && !markerIsCorroborated && !explicitCompletionIsCorroborated) {
throw new Error(`Claude result has no standalone marker: ${marker}`);
}
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const [
evidencePath,
marker,
requiredTools = "",
requireNpmTest = "false",
requiredSlashCommand = "",
requiredSkill = "",
acceptExplicitCompletion = "false",
] = process.argv.slice(2);
if (!evidencePath)
throw new Error("Usage: validate-claude-evidence.mjs FILE MARKER [TOOLS] [NPM_TEST]");
validateClaudeEvidenceText(fs.readFileSync(evidencePath, "utf8"), {
marker,
requiredTools: requiredTools
.split(",")
.map((value) => value.trim())
.filter(Boolean),
requireSuccessfulNpmTest: requireNpmTest === "true",
requiredSlashCommand: requiredSlashCommand || undefined,
requiredSkill: requiredSkill || undefined,
acceptExplicitCompletion: acceptExplicitCompletion === "true",
});
process.stdout.write(`PASS: validated Claude evidence for ${marker}\n`);
}

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/common"
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
bridge_prepare_sandbox
compose_config=(docker compose -f "$BRIDGE_COMPOSE" --env-file /dev/null --profile offline --profile live-devin config)
config="$("${compose_config[@]}")"
config_json="$("${compose_config[@]}" --format json)"
for forbidden in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.ssh" "/var/run/docker.sock"; do
[[ "$config" != *"$forbidden"* ]] || fail "forbidden host mount appears in compose: $forbidden"
done
grep -q 'user: 10001:10001' <<<"$config" || fail "runtime is not non-root"
grep -q 'read_only: true' <<<"$config" || fail "runtime root filesystem is not read-only"
grep -q 'internal: true' <<<"$config" || fail "internal network is missing"
grep -q 'CLAUDE_CONFIG_DIR: /home/bridge/.claude-devin-isolated' <<<"$config" || fail "isolated Claude config is missing"
node -e '
const fs = require("node:fs");
const config = JSON.parse(fs.readFileSync(0, "utf8"));
const liveNetworks = Object.keys(config.services["omniroute-live"].networks || {}).sort();
if (JSON.stringify(liveNetworks) !== JSON.stringify(["bridge-internal", "devin-guard-internal"])) {
throw new Error(`live runtime network escape: ${liveNetworks.join(",")}`);
}
const guardNetworks = Object.keys(config.services["network-guard"].networks || {}).sort();
if (JSON.stringify(guardNetworks) !== JSON.stringify(["devin-guard-internal", "guard-egress"])) {
throw new Error(`network guard topology mismatch: ${guardNetworks.join(",")}`);
}
const claudeGuard = config.services["claude-egress-guard"];
if (JSON.stringify(Object.keys(claudeGuard.networks || {})) !== JSON.stringify(["bridge-internal"])) {
throw new Error("Claude egress guard must remain on the internal network only");
}
if (config.services["network-guard"].environment.GUARD_POLICY !== "devin") {
throw new Error("Devin network guard policy mismatch");
}
if (claudeGuard.environment.GUARD_POLICY !== "deny-all") {
throw new Error("Claude egress guard is not deny-all");
}
for (const guardName of ["network-guard", "claude-egress-guard"]) {
const guard = config.services[guardName];
const env = config.services[guardName].environment;
if (env.GUARD_ALLOW_SUFFIXES || env.GUARD_ALLOW_HOSTS) {
throw new Error(`${guardName} exposes mutable host allowlists`);
}
if (!guard.healthcheck?.test) throw new Error(`${guardName} has no healthcheck`);
const auditMount = (guard.volumes || []).find((mount) => mount.target === "/guard-audit");
if (!auditMount || auditMount.type !== "bind" || !auditMount.source.includes("/.sandbox/guard-audit/")) {
throw new Error(`${guardName} does not use its guard-only audit bind`);
}
}
const runtimeNames = ["omniroute", "claude", "contract", "omniroute-live", "claude-live"];
for (const serviceName of [...runtimeNames, "network-guard", "claude-egress-guard"]) {
const service = config.services[serviceName];
if (String(service.user) !== "10001:10001" || !service.read_only) {
throw new Error(`${serviceName} is not non-root and read-only`);
}
}
for (const serviceName of runtimeNames) {
const service = config.services[serviceName];
if ((service.volumes || []).some((mount) => mount.target === "/guard-audit")) {
throw new Error(`${serviceName} can mutate guard audit evidence`);
}
const namedVolumes = (service.volumes || []).filter((mount) => mount.type === "volume");
const hasClaudeConfig = namedVolumes.some(
(mount) => mount.target === "/home/bridge/.claude-devin-isolated",
);
const hasDevinAuth = namedVolumes.some(
(mount) => mount.target === "/home/bridge/.local/share/devin",
);
const expectsClaudeConfig = serviceName === "claude" || serviceName === "claude-live";
const expectsDevinAuth = serviceName === "omniroute-live";
if (hasClaudeConfig !== expectsClaudeConfig) {
throw new Error(`${serviceName} Claude config volume ownership mismatch`);
}
if (hasDevinAuth !== expectsDevinAuth) {
throw new Error(`${serviceName} Devin auth volume ownership mismatch`);
}
for (const key of [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]) {
if (!String(service.environment[key] || "").startsWith("devin-cli-agentic/")) {
throw new Error(`${serviceName} has a non-Devin model alias in ${key}`);
}
}
}
if (config.services["omniroute-live"].depends_on["network-guard"].condition !== "service_healthy") {
throw new Error("omniroute-live does not wait for a healthy Devin guard");
}
for (const serviceName of ["claude", "claude-live"]) {
if (config.services[serviceName].depends_on["claude-egress-guard"].condition !== "service_healthy") {
throw new Error(`${serviceName} does not wait for a healthy Claude guard`);
}
}
const liveEnv = config.services["omniroute-live"].environment;
if (liveEnv.DEVIN_BRIDGE_PROXY_URL !== "http://network-guard:8080") {
throw new Error("trusted Devin bridge proxy is missing");
}
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]) {
if (liveEnv[key]) throw new Error(`omniroute-live must not inherit ${key}`);
}
for (const serviceName of runtimeNames.filter((name) => name !== "omniroute-live")) {
if (config.services[serviceName].environment.DEVIN_BRIDGE_PROXY_URL) {
throw new Error(`${serviceName} received the Devin bridge proxy setting`);
}
}
for (const serviceName of ["claude", "claude-live"]) {
const env = config.services[serviceName].environment;
if (
env.NODE_USE_ENV_PROXY !== "1" ||
env.HTTP_PROXY !== "http://claude-egress-guard:8080" ||
env.HTTPS_PROXY !== "http://claude-egress-guard:8080" ||
env.NO_PROXY !== "omniroute"
) {
throw new Error(`${serviceName} does not use the deny-all Claude guard`);
}
if (env.HTTP_PROXY === "http://network-guard:8080") {
throw new Error(`${serviceName} received the Devin-capable guard`);
}
}
' <<<"$config_json" || fail "structured compose isolation checks failed"
node --input-type=module -e '
import { pathToFileURL } from "node:url";
const policy = await import(pathToFileURL(process.argv[1]));
const allowed = [
"devin.ai",
"api.devin.ai",
"cognition.ai",
"api.cognition.ai",
"server.codeium.com",
"unleash.codeium.com",
];
const denied = [
"evildevin.ai",
"codeium.com",
"api.codeium.com",
"o123.ingest.sentry.io",
"api.anthropic.com",
"claude.ai",
];
for (const hostname of allowed) {
if (!policy.isAllowedGuardHostname(hostname, "devin")) throw new Error(`denied ${hostname}`);
}
for (const hostname of denied) {
if (policy.isAllowedGuardHostname(hostname, "devin")) throw new Error(`allowed ${hostname}`);
}
if (policy.isAllowedGuardHostname("api.devin.ai", "deny-all")) {
throw new Error("deny-all guard allowed Devin traffic");
}
' "$BRIDGE_ROOT/docker/devin-bridge/network-guard/policy.mjs" || fail "network guard policy checks failed"
bridge_test_env node --import tsx/esm --input-type=module -e '
import { pathToFileURL } from "node:url";
const { buildDevinChildEnv } = await import(pathToFileURL(process.argv[1]));
const home = process.env.DEVIN_AGENTIC_HOME;
const trusted = buildDevinChildEnv({}, {
DEVIN_AGENTIC_HOME: home,
DEVIN_BRIDGE_PROXY_URL: "http://network-guard:8080",
HTTP_PROXY: "http://user:password@host-proxy.example:3128",
HTTPS_PROXY: "http://user:password@host-proxy.example:3128",
ALL_PROXY: "socks5://host-proxy.example:1080",
});
if (
trusted.HTTP_PROXY !== "http://network-guard:8080" ||
trusted.HTTPS_PROXY !== "http://network-guard:8080" ||
trusted.ALL_PROXY
) {
throw new Error("trusted child proxy derivation failed");
}
const untrusted = buildDevinChildEnv({}, {
DEVIN_AGENTIC_HOME: home,
DEVIN_BRIDGE_PROXY_URL: "http://user:password@network-guard:8080",
HTTP_PROXY: "http://host-proxy.example:3128",
});
if (untrusted.HTTP_PROXY || untrusted.HTTPS_PROXY) {
throw new Error("untrusted child proxy was inherited");
}
' "$BRIDGE_ROOT/open-sse/executors/devin-cli-agentic.ts" || \
fail "Devin child proxy boundary checks failed"
bridge_assert_devin_auth_status 0 $'Logged in (via Devin)\n' || fail "clean auth fixture was rejected"
if bridge_assert_devin_auth_status 0 $'Logged in (via Devin)\nFailed to fetch from server\n' 2>/dev/null; then
fail "server-fetch auth failure was accepted"
fi
if bridge_assert_devin_auth_status 0 $'Logged out\n' 2>/dev/null; then
fail "logged-out auth fixture was accepted"
fi
if bridge_assert_devin_auth_status 0 $'Not Logged in (via Devin)\n' 2>/dev/null; then
fail "misleading auth fixture was accepted"
fi
selected_model="$(printf '%s' '{"models":[{"family_uid":"swe-1.7"},{"modelUid":"swe-1.7-lightning"}]}' | \
node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs")"
[[ "$selected_model" == swe-1-7-lightning ]] || fail "live model normalization or preference failed"
if printf '%s' '{"models":[{"family_uid":"unknown.9"}]}' | \
node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" >/dev/null 2>&1; then
fail "unknown normalized live model was accepted"
fi
grep -q 'bridge_run_devin auth login --force-manual-token-flow' \
"$BRIDGE_ROOT/scripts/devin-bridge/login-devin" || fail "manual token login flow is missing"
if grep -Eqi 'read[[:space:]].*token|printf[[:space:]].*token|echo[[:space:]].*token' \
"$BRIDGE_ROOT/scripts/devin-bridge/login-devin"; then
fail "login script could expose a token"
fi
grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/test-live-devin" || \
fail "live test bypasses strict auth status"
grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \
fail "normal launch bypasses strict auth status"
grep -q 'up -d --wait network-guard claude-egress-guard' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \
fail "normal launch does not start the audited Claude egress guard"
grep -qx '\.sandbox' "$BRIDGE_ROOT/.dockerignore" || fail ".sandbox is not excluded from builds"
if [[ "${1:-}" == --static ]]; then printf 'PASS: static bridge isolation checks passed\n'; exit 0; fi
trap bridge_cleanup_compose EXIT
bridge_cleanup_compose
bridge_reset_claude_egress_audit
docker compose -f "$BRIDGE_COMPOSE" --profile offline up -d --wait claude-egress-guard
docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude bash -ceu '
test "$(id -u)" = 10001
test "$HOME" = /home/bridge
test "$CLAUDE_CONFIG_DIR" = /home/bridge/.claude-devin-isolated
test "$ANTHROPIC_BASE_URL" = http://omniroute:20128
test "$ANTHROPIC_AUTH_TOKEN" = sk-local-devin-gateway
test -z "${ANTHROPIC_API_KEY:-}${CLAUDE_CODE_OAUTH_TOKEN:-}${AWS_ACCESS_KEY_ID:-}${AWS_SECRET_ACCESS_KEY:-}${GOOGLE_APPLICATION_CREDENTIALS:-}${AZURE_OPENAI_API_KEY:-}"
test ! -e /var/run/docker.sock
if touch /bridge-must-remain-read-only 2>/dev/null; then
echo "container root filesystem is writable" >&2; exit 1
fi
for host in api.anthropic.com claude.ai; do
if node -e "require(\"net\").connect(443,process.argv[1]).on(\"connect\",()=>process.exit(0)).on(\"error\",()=>process.exit(1)).setTimeout(1500,()=>process.exit(1))" "$host"; then
echo "unexpected network access to $host" >&2; exit 1
fi
done
'
docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude \
node --input-type=module -e '
async function expectProxyDenial(request) {
try {
const response = await request;
if (response.status !== 403) {
throw new Error(`unexpected proxy response: ${response.status}`);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("unexpected proxy response:")) {
throw error;
}
}
}
await expectProxyDenial(fetch("https://api.anthropic.com", {
signal: AbortSignal.timeout(3000),
}));
await expectProxyDenial(fetch("https://claude.ai", {
signal: AbortSignal.timeout(3000),
}));
'
bridge_cleanup_compose
bridge_assert_claude_guard_denials "$BRIDGE_CLAUDE_AUDIT" || \
fail "Claude proxy denial audit proof failed"
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress-verifier.jsonl
trap - EXIT
bridge_reset_claude_egress_audit
printf 'PASS: runtime bridge isolation checks passed\n'

View File

@@ -67,21 +67,76 @@ export function patchJsonManifestFile(filePath, basePath) {
}
const BASE_PATH_LITERAL_RE =
/basePath\s*:\s*(?:""|''|`{2})|basePath\s*:\s*void 0|"basePath"\s*:\s*""/g;
/(?:basePath|assetPrefix)\s*:\s*(?:""|''|``)|(?:basePath|assetPrefix)\s*:\s*void 0|"(?:basePath|assetPrefix)"\s*:\s*""|"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"\s*:\s*""|NEXT_PUBLIC_OMNIROUTE_BASE_PATH\s*:\s*""/g;
/**
* Rewrite the bare config literals Next bakes into the standalone output:
* - `basePath` (routing + server-rendered links) — the original scope;
* - `assetPrefix` (Next 16 app-router renders SSR asset URLs from
* `assetPrefix` ALONE — basePath only affects routing, so a subpath
* deploy must mirror it or every `/_next/static` shell reference 404s);
* - the `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` env mirror in the inline
* nextConfig (server.js) so server-side env reads stay consistent.
*
* @param {string} content
* @param {string} basePath
*/
export function patchBasePathLiterals(content, basePath) {
const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return content.replace(BASE_PATH_LITERAL_RE, (match) => {
if (match.startsWith('"basePath"')) return `"basePath":"${escaped}"`;
if (match.includes("void 0")) return `basePath:"${escaped}"`;
return `basePath:"${escaped}"`;
if (match.startsWith('"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"')) {
return `"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":"${escaped}"`;
}
if (match.startsWith("NEXT_PUBLIC_OMNIROUTE_BASE_PATH")) {
return `NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`;
}
if (match.startsWith('"')) {
// `"basePath":""` / `"assetPrefix":""` (JSON-ish inline config)
const key = match.slice(1, match.indexOf('"', 1));
return `"${key}":"${escaped}"`;
}
// `basePath:""` / `basePath:void 0` / `assetPrefix:""` (minified code)
const key = match.slice(0, match.indexOf(":")).trim();
return `${key}:"${escaped}"`;
});
}
/**
* Turbopack's client `process` shim ships an empty env object (`.env={}`).
* Next 16's client code reads NEXT_PUBLIC_* / OMNIROUTE_BASE_PATH from it at
* runtime, so without this the client never learns the subpath and the
* dashboard's fetch/EventSource rewriting (basePathFetch) silently stays on
* the root path. Populate the two keys the app reads.
*
* @param {string} content
* @param {string} basePath
*/
export function patchProcessEnvShim(content, basePath) {
const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return content.replace(/\.env=\{\}/g, () => {
const keys = `OMNIROUTE_BASE_PATH:"${escaped}",NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`;
return `.env={${keys}}`;
});
}
/**
* Rewrite baked absolute asset URLs (`"/_next/static/..."`) to the subpath.
* Covers the client-reference-manifest chunk lists (they are serialized into
* the RSC flight payload verbatim) and the client/server chunk media imports
* — every `/ _next/static` reference must be prefixed because the standalone
* server only serves assets under basePath.
*
* @param {string} content
* @param {string} basePath
*/
export function patchBakedAssetUrls(content, basePath) {
const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return content.replace(
/(["'`])\/_next\/static/g,
(_match, quote) => `${quote}${escaped}/_next/static`
);
}
/**
* @param {string} rootDir
* @param {string} basePath
@@ -98,9 +153,12 @@ function walkAndPatchTextFiles(rootDir, basePath) {
stack.push(full);
continue;
}
if (!/\.(?:js|json|cjs|mjs)$/.test(entry.name)) continue;
if (!/\.(?:js|json|cjs|mjs|html)$/.test(entry.name)) continue;
const before = fs.readFileSync(full, "utf8");
const after = patchBasePathLiterals(before, basePath);
const after = [patchBasePathLiterals, patchProcessEnvShim, patchBakedAssetUrls].reduce(
(content, patch) => patch(content, basePath),
before
);
if (after !== before) {
fs.writeFileSync(full, after);
patchedFiles += 1;

View File

@@ -7,6 +7,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import {
FREE_PROVIDERS,
NOAUTH_PROVIDERS,
OAUTH_PROVIDERS,
WEB_COOKIE_PROVIDERS,
APIKEY_PROVIDERS,
@@ -136,6 +137,7 @@ function buildHeader(total: number): string {
"## Categories",
"",
"- **Free** — free tier with API key (configured via dashboard)",
"- **No-auth** — public endpoints that require no key or sign-in at all",
"- **OAuth** — sign-in flow handled by OmniRoute, no API key needed",
"- **Web cookie** — wraps the provider's web app via cookie auth",
"- **API key** — paid provider configured via API key (free credits may apply)",
@@ -159,8 +161,19 @@ function buildHeader(total: number): string {
].join("\n");
}
function countExecutorImpls(): number {
const dir = path.join(ROOT, "open-sse", "executors");
const nonImpl = new Set(["index.ts", "index.mts", "types.ts", "base.ts", "constants.ts"]);
return fs
.readdirSync(dir)
.filter(
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.startsWith("__") && !nonImpl.has(f)
).length;
}
function main() {
const free = asRecords(FREE_PROVIDERS);
const noauth = asRecords(NOAUTH_PROVIDERS as Record<string, ProviderRecord>);
const oauth = asRecords(OAUTH_PROVIDERS);
const webCookie = asRecords(WEB_COOKIE_PROVIDERS);
const apiKey = asRecords(APIKEY_PROVIDERS);
@@ -173,6 +186,7 @@ function main() {
const allIds = new Set<string>([
...free.map((p) => p.id),
...noauth.map((p) => p.id),
...oauth.map((p) => p.id),
...webCookie.map((p) => p.id),
...apiKey.map((p) => p.id),
@@ -186,6 +200,7 @@ function main() {
const sections = [
buildSection("Free Tier (OAuth-first or no-key)", free, "Free"),
buildSection("No-auth Providers (no key required)", noauth, "No-auth"),
buildSection("OAuth Providers", oauth, "OAuth"),
buildSection("Web Cookie Providers", webCookie, "Web cookie"),
buildSection("API Key Providers (paid / paid-with-free-credits)", apiKey, "API key"),
@@ -202,7 +217,7 @@ function main() {
"",
"- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)",
"- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)",
"- Executors: [`open-sse/executors/`](../../open-sse/executors/) (31 files)",
`- Executors: [\`open-sse/executors/\`](../../open-sse/executors/) (${countExecutorImpls()} implementations)`,
"- Translators: [`open-sse/translator/`](../../open-sse/translator/)",
"",
"## See Also",
@@ -218,9 +233,10 @@ function main() {
console.log(`✓ Wrote ${OUT_FILE}`);
console.log(` Providers: ${allIds.size} unique IDs`);
console.log(
` Sections: free=${free.length}, oauth=${oauth.length}, web=${webCookie.length}, ` +
`apikey=${apiKey.length}, local=${local.length}, search=${search.length}, ` +
`audio=${audio.length}, proxy=${upstreamProxy.length}, cloud=${cloudAgent.length}, system=${system.length}`
` Sections: free=${free.length}, noauth=${noauth.length}, oauth=${oauth.length}, ` +
`web=${webCookie.length}, apikey=${apiKey.length}, local=${local.length}, ` +
`search=${search.length}, audio=${audio.length}, proxy=${upstreamProxy.length}, ` +
`cloud=${cloudAgent.length}, system=${system.length}`
);
}

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env node
// One-shot: FASE 3 helper, safe to delete after merge.
//
// Moves existing i18n mirror docs from `docs/i18n/<lang>/docs/X.md` into the
// matching subfolder `docs/i18n/<lang>/docs/<sub>/X.md`, mirroring the new
// docs/ layout. Uses `git mv` to preserve history.
//
// Usage:
// node scripts/docs/move-i18n-mirrors.mjs [--dry]
//
// Notes:
// - Skips files that don't appear in DOC_TO_SUBFOLDER (e.g., the legacy
// `cloudflare-zero-trust-guide.md` or `features/` subfolder — those will be
// handled in FASE 5 when translations are regenerated).
// - Idempotent: if the target already lives under a subfolder, the entry is
// skipped.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const I18N_DIR = path.join(ROOT, "docs", "i18n");
const DRY = process.argv.includes("--dry");
const DOC_TO_SUBFOLDER = {
// architecture
"ARCHITECTURE.md": "architecture",
"CODEBASE_DOCUMENTATION.md": "architecture",
"REPOSITORY_MAP.md": "architecture",
"AUTHZ_GUIDE.md": "architecture",
"RESILIENCE_GUIDE.md": "architecture",
// guides
"SETUP_GUIDE.md": "guides",
"USER_GUIDE.md": "guides",
"DOCKER_GUIDE.md": "guides",
"ELECTRON_GUIDE.md": "guides",
"TERMUX_GUIDE.md": "guides",
"PWA_GUIDE.md": "guides",
"TROUBLESHOOTING.md": "guides",
"UNINSTALL.md": "guides",
"I18N.md": "guides",
"FEATURES.md": "guides",
// reference
"API_REFERENCE.md": "reference",
"PROVIDER_REFERENCE.md": "reference",
"openapi.yaml": "reference",
"ENVIRONMENT.md": "reference",
"CLI-TOOLS.md": "reference",
"FREE_TIERS.md": "reference",
// frameworks
"MCP-SERVER.md": "frameworks",
"A2A-SERVER.md": "frameworks",
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
"CLOUD_AGENT.md": "frameworks",
"SKILLS.md": "frameworks",
"MEMORY.md": "frameworks",
"WEBHOOKS.md": "frameworks",
"EVALS.md": "frameworks",
// routing
"AUTO-COMBO.md": "routing",
"REASONING_REPLAY.md": "routing",
// security
"GUARDRAILS.md": "security",
"COMPLIANCE.md": "security",
"STEALTH_GUIDE.md": "security",
// compression
"COMPRESSION_GUIDE.md": "compression",
"COMPRESSION_ENGINES.md": "compression",
"COMPRESSION_RULES_FORMAT.md": "compression",
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
"RTK_COMPRESSION.md": "compression",
// ops
"RELEASE_CHECKLIST.md": "ops",
"COVERAGE_PLAN.md": "ops",
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
"VM_DEPLOYMENT_GUIDE.md": "ops",
"PROXY_GUIDE.md": "ops",
"TUNNELS_GUIDE.md": "ops",
};
let moved = 0;
let skipped = 0;
const seenLocales = [];
for (const locale of fs.readdirSync(I18N_DIR)) {
const localeDir = path.join(I18N_DIR, locale);
const stat = fs.statSync(localeDir);
if (!stat.isDirectory()) continue;
const docsDir = path.join(localeDir, "docs");
if (!fs.existsSync(docsDir)) continue;
seenLocales.push(locale);
for (const fname of fs.readdirSync(docsDir)) {
const sub = DOC_TO_SUBFOLDER[fname];
if (!sub) continue; // not in our mapping (e.g. features/, cloudflare-zero-trust-guide.md)
const src = path.join(docsDir, fname);
if (!fs.statSync(src).isFile()) continue;
const subDir = path.join(docsDir, sub);
const dst = path.join(subDir, fname);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
if (DRY) {
console.log(`would move: ${path.relative(ROOT, src)} -> ${path.relative(ROOT, dst)}`);
moved++;
continue;
}
if (!fs.existsSync(subDir)) fs.mkdirSync(subDir, { recursive: true });
const relSrc = path.relative(ROOT, src);
const relDst = path.relative(ROOT, dst);
try {
execFileSync("git", ["mv", "-k", "--", relSrc, relDst], {
cwd: ROOT,
stdio: "pipe",
});
moved++;
} catch {
// fallback: copy + delete; emulate `|| true` for the rm by ignoring its failure
fs.renameSync(src, dst);
try {
execFileSync("git", ["rm", "--cached", "--", relSrc], { cwd: ROOT, stdio: "pipe" });
} catch {
// file may not be tracked yet — safe to ignore
}
execFileSync("git", ["add", "--", relDst], { cwd: ROOT, stdio: "pipe" });
moved++;
}
}
}
console.log(
`[i18n-mirrors] locales=${seenLocales.length} moved=${moved} skipped=${skipped}${DRY ? " (dry-run)" : ""}`
);

View File

@@ -10,10 +10,13 @@
* - protected-term-altered: a value renders a protected product/provider/
* protocol/CLI/env identifier (scripts/i18n/glossary/protected-terms.json)
* using a known incorrect translation instead of leaving it verbatim.
* Known incorrect renderings come from the legacy KNOWN_MISTRANSLATIONS
* map below (zh-CN) merged with the optional per-locale
* `protectedTermMistranslations` object in the locale's glossary file (ko).
*
* Usage:
* node scripts/i18n/check-glossary-consistency.mjs # zh-CN, exit 1 on drift
* node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
* node scripts/i18n/check-glossary-consistency.mjs --locale=ko
* node scripts/i18n/check-glossary-consistency.mjs --json
* node scripts/i18n/check-glossary-consistency.mjs --report # print, always exit 0
*/
@@ -30,6 +33,9 @@ const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const GLOSSARY_DIR = path.join(SCRIPT_DIR, "glossary");
const LOG_PREFIX = "[i18n-glossary]";
// Legacy zh-CN map of known incorrect renderings for protected terms — newer
// locales (ko) keep theirs in `protectedTermMistranslations` inside their
// scripts/i18n/glossary/<locale>.json instead of growing this constant.
// Small, maintained map of known incorrect renderings for protected terms —
// identifiers that must survive translation verbatim. NOT exhaustive by
// design (a full back-translation model is out of scope for a static gate),
@@ -97,10 +103,16 @@ export function checkGlossaryConsistency(localeMessages, glossary, protectedTerm
}
}
const localeMistranslations = isPlainObject(glossary?.protectedTermMistranslations)
? glossary.protectedTermMistranslations
: {};
const protectedList = Array.isArray(protectedTerms) ? protectedTerms : [];
for (const term of protectedList) {
const badRenderings = KNOWN_MISTRANSLATIONS[term];
if (!badRenderings || badRenderings.length === 0) continue;
const fromGlossary = Array.isArray(localeMistranslations[term])
? localeMistranslations[term]
: [];
const badRenderings = [...(KNOWN_MISTRANSLATIONS[term] || []), ...fromGlossary];
if (badRenderings.length === 0) continue;
for (const bad of badRenderings) {
for (const leaf of leaves) {
if (leaf.value.includes(bad)) {

View File

@@ -78,6 +78,39 @@ export function flattenLeaves(node, prefix = "", out = {}) {
* @param {Record<string, object>} args.headLocales locale -> catalog in the working tree
* @returns {Array<{ key: string, locale: string }>} sorted, stable
*/
/**
* Whether an English rewrite is COSMETIC — the same sentence, differently cased, spaced, or
* terminally punctuated. A translation of the old string is still a correct translation of the
* new one, so it must not be marked stale.
*
* Why this exists: `"Reset Defaults"` → `"Reset defaults"` marked the key stale in 41 locales
* during the v3.8.49 cycle. Every one of those translations was still correct, and in locales
* with no letter case the "fix" is not even expressible. Worse, the documented escape hatch
* (a `__MISSING__:` placeholder) is BANNED in `vi` by tests/unit/i18n-vi-completeness.test.ts,
* so `vi` had no legitimate way out of a purely cosmetic English edit.
*
* Deliberately narrow: ONLY letter case and trailing terminal punctuation.
*
* **Whitespace is NOT folded, on purpose.** A first version of this folded whitespace runs and
* trimmed, and it collided with a pre-existing, deliberately conservative decision —
* tests/unit/i18n-ui-value-drift.test.ts, "a value that only changes whitespace still counts as
* an edit", whose comment reads: *"trailing-space churn is rare, and treating it as a no-op would
* let a real reword slip through behind an innocuous-looking diff."* That call belongs to whoever
* made it; the problem actually reported here was CASE
* (`"Reset Defaults"` → `"Reset defaults"`, 41 locales invalidated), and quietly reversing
* someone else's documented decision to fix something they never reported is not this change's
* job. Narrowed to the reported scope instead.
*
* Any change to the words themselves — added or removed words, and any change inside an
* interpolation like `{count}` — is a real rewrite and still flags.
*/
export function isCosmeticRewrite(before, after) {
if (typeof before !== "string" || typeof after !== "string") return false;
if (before === after) return true;
const norm = (s) => s.replace(/[.:!?;,]+$/u, "").toLowerCase();
return norm(before) === norm(after);
}
export function findStaleTranslations({ baseEn, headEn, baseLocales, headLocales }) {
const baseFlat = flattenLeaves(baseEn);
const headFlat = flattenLeaves(headEn);
@@ -85,8 +118,14 @@ export function findStaleTranslations({ baseEn, headEn, baseLocales, headLocales
// Keys whose English copy was REWRITTEN by this change. A key absent from either side
// is an add or a delete: nothing can be stale against text that did not exist, and a
// renamed key (delete + add) is exactly the safe fix pattern.
//
// Cosmetic rewrites (case, spacing, trailing punctuation) are excluded — see
// isCosmeticRewrite. They used to invalidate correct translations in 41 locales at once.
const rewritten = Object.keys(headFlat).filter(
(key) => key in baseFlat && baseFlat[key] !== headFlat[key]
(key) =>
key in baseFlat &&
baseFlat[key] !== headFlat[key] &&
!isCosmeticRewrite(baseFlat[key], headFlat[key])
);
if (rewritten.length === 0) return [];

View File

@@ -0,0 +1,55 @@
{
"version": 1,
"locale": "ko",
"description": "Canonical ko terminology for recurring OmniRoute concepts. Consumed by scripts/i18n/check-glossary-consistency.mjs. Each concept lists the canonical translation plus any non-canonical synonym that is actively normalized (drift enforced by the consistency gate). Concepts whose `synonyms` array is empty are seeded for documentation only — the catalog still uses more than one legitimate rendering for them today (e.g. 공급자/제공자, 폴백/대체), so enforcement is deferred to a follow-up normalization pass. Every enforced synonym and mistranslation below was verified to have zero legitimate occurrences in the real src + bin/cli ko catalogs before being added (collision policy mirrors the KNOWN_MISTRANSLATIONS note in the checker script — e.g. 안타 (Hits) is deliberately NOT enforced because it is a substring of the legitimate 안타깝게도).",
"terms": {
"provider": {
"canonical": "공급자",
"synonyms": []
},
"fallback": {
"canonical": "폴백",
"synonyms": []
},
"running (status)": {
"canonical": "실행 중",
"synonyms": ["달리기"]
},
"disabled (status)": {
"canonical": "비활성화됨",
"synonyms": ["장애인"]
},
"key (credential)": {
"canonical": "키",
"synonyms": ["열쇠"]
},
"export (action)": {
"canonical": "내보내기",
"synonyms": ["수출"]
},
"healthcheck": {
"canonical": "상태 확인",
"synonyms": ["건강검진"]
},
"port (network)": {
"canonical": "포트",
"synonyms": ["항구"]
},
"artifacts": {
"canonical": "아티팩트",
"synonyms": ["유물"]
}
},
"protectedTermMistranslations": {
"ngrok": ["응록"],
"Anthropic": ["인류", "앤트로픽"],
"Claude": ["클로드"],
"Gemini": ["쌍둥이자리"],
"Antigravity": ["반중력"],
"OmniRoute": ["옴니루트"],
"Tailscale": ["꼬리비늘"],
"VACUUM": ["진공"],
"socks5": ["양말5"],
"ZIP": ["우편번호"]
}
}

View File

@@ -1,5 +1,5 @@
{
"description": "Product/provider/model/protocol/header/CLI/env/identifier names that must appear verbatim (untranslated) inside any zh-CN localized string that mentions them. Distinct from untranslatable-keys.json, which excludes whole KEYS from drift checks at key-granularity; this list is consumed by scripts/i18n/check-glossary-consistency.mjs to flag a VALUE that mentions the concept but altered/translated the protected term itself.",
"description": "Product/provider/model/protocol/header/CLI/env/identifier names that must appear verbatim (untranslated) inside any localized string that mentions them (gated locales: zh-CN, ko). Distinct from untranslatable-keys.json, which excludes whole KEYS from drift checks at key-granularity; this list is consumed by scripts/i18n/check-glossary-consistency.mjs to flag a VALUE that mentions the concept but altered/translated the protected term itself. Known incorrect renderings live per-locale: legacy zh-CN entries in the checker's KNOWN_MISTRANSLATIONS map, newer locales in `protectedTermMistranslations` inside scripts/i18n/glossary/<locale>.json.",
"terms": [
"OmniRoute",
"OAuth",
@@ -20,6 +20,15 @@
"CLI",
"Docker",
"Electron",
"Playwright"
"Playwright",
"ngrok",
"Anthropic",
"Claude",
"Gemini",
"Antigravity",
"Tailscale",
"VACUUM",
"socks5",
"ZIP"
]
}

View File

@@ -42,6 +42,10 @@
"circuit breaker": {
"canonical": "断路器",
"synonyms": []
},
"disabled (status)": {
"canonical": "已禁用",
"synonyms": ["残疾人"]
}
}
}

View File

@@ -78,6 +78,10 @@
"canonical": "專案",
"synonyms": [],
"note": "Enforcement deferred: 項目 is also the correct rendering of 'item' (依賴項目, 必要項目, 共通項目), which dominates real usage. Only 項目概覽 -> 專案概覽 is normalized by hand."
},
"disabled (status)": {
"canonical": "已停用",
"synonyms": ["殘疾人", "殘障人士"]
}
}
}

View File

@@ -1,29 +0,0 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_SRC="$(dirname "$SCRIPT_DIR")/obsidian-plugin"
DESKTOP_VAULT="${1:-$HOME/Documents/Vault/Omniroute-Test}"
MOBILE_VAULT="${2:-$HOME/Documents/Vault/Test}"
echo "Building plugin..."
cd "$PLUGIN_SRC"
npm run build 2>&1 | tail -3
echo "Installing to desktop vault: $DESKTOP_VAULT"
mkdir -p "$DESKTOP_VAULT/.obsidian/plugins/omniroute-sync"
cp "$PLUGIN_SRC/dist/main.js" "$DESKTOP_VAULT/.obsidian/plugins/omniroute-sync/"
cp "$PLUGIN_SRC/manifest.json" "$DESKTOP_VAULT/.obsidian/plugins/omniroute-sync/"
cp "$PLUGIN_SRC/styles.css" "$DESKTOP_VAULT/.obsidian/plugins/omniroute-sync/"
echo " ✓ Desktop plugin installed"
if [ -d "$MOBILE_VAULT" ]; then
echo "Installing to mobile vault: $MOBILE_VAULT"
mkdir -p "$MOBILE_VAULT/.obsidian/plugins/omniroute-sync"
cp "$PLUGIN_SRC/dist/main.js" "$MOBILE_VAULT/.obsidian/plugins/omniroute-sync/"
cp "$PLUGIN_SRC/manifest.json" "$MOBILE_VAULT/.obsidian/plugins/omniroute-sync/"
cp "$PLUGIN_SRC/styles.css" "$MOBILE_VAULT/.obsidian/plugins/omniroute-sync/"
echo " ✓ Mobile plugin installed"
fi
echo "Done! Restart Obsidian on both devices to load the plugin."

View File

@@ -0,0 +1,427 @@
{
"code": "200",
"data": {
"DataV2": {
"data": {
"data": {
"freeTierQuotas": [
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-2025-09-08",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-30b-a3b-captioner",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vd-2026-01-26",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vc-realtime-2026-01-15",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "cosyvoice-v3-flash",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash-realtime",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-voice-enrollment",
"quotaTotal": 1000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-2025-08-25",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-mtl",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-realtime-2025-11-07",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash-2025-09-18",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash-realtime-2025-09-18",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-realtime-2026-02-10",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-livetranslate-flash-realtime-2025-09-22",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vc-2026-01-22",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1791820800000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-audio-3.0-tts-flash",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-instruct-flash-2026-01-26",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-2025-11-07",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash-2025-11-27",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vc-realtime-2025-11-27",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-realtime-2025-10-27",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-realtime",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vd-realtime-2025-12-16",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "cosyvoice-v3-plus",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-instruct-flash-realtime-2026-01-22",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-2026-02-10",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-instruct-flash",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-instruct-flash-realtime",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-mtl-2025-08-25",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1791820800000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-audio-3.0-tts-plus",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-vd-realtime-2026-01-15",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-livetranslate-flash",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-realtime",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-filetrans",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash-filetrans-2025-11-17",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-livetranslate-flash-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-tts-flash-realtime-2025-11-27",
"quotaTotal": 10000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-livetranslate-flash-realtime-2026-05-19",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-livetranslate-flash-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-livetranslate-flash-2025-12-01",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-asr-flash",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 36000,
"quotaValidityPeriod": 1789488000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "fun-asr-flash-2026-06-15",
"quotaTotal": 36000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 40,
"model": "qwen-voice-design",
"quotaTotal": 4,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 0,
"freeTierOnly": false,
"quotaTotalPercentage": 0,
"model": "voice-enrollment",
"quotaTotal": 0,
"quotaStatus": "VALID"
}
]
},
"success": true
}
}
}
}

View File

@@ -0,0 +1,201 @@
{
"code": "200",
"data": {
"DataV2": {
"data": {
"data": {
"freeTierQuotas": [
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-plus-2026-03-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash-realtime-2025-09-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-omni-turbo-realtime-2025-05-08",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-flash-realtime-2026-03-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-plus",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash-2025-12-01",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-omni-turbo-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-omni-turbo",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen2.5-omni-7b",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-plus-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen-omni-turbo-2025-03-26",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-flash",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash-realtime-2025-12-01",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-plus-realtime-2026-03-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-flash-realtime",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3.5-omni-flash-2026-03-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 1000000,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": true,
"quotaTotalPercentage": 100,
"model": "qwen3-omni-flash-2025-09-15",
"quotaTotal": 1000000,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 0,
"freeTierOnly": false,
"quotaTotalPercentage": 0,
"model": "qwen-omni-turbo-realtime-latest",
"quotaTotal": 0,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 0,
"freeTierOnly": false,
"quotaTotalPercentage": 0,
"model": "qwen-omni-turbo-latest",
"quotaTotal": 0,
"quotaStatus": "VALID"
}
]
},
"success": true
}
}
}
}

View File

@@ -0,0 +1,573 @@
{
"code": "200",
"data": {
"DataV2": {
"ret": ["SUCCESS::接口调用成功"],
"data": {
"data": {
"freeTierQuotas": [
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-vace-plus",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-videoedit",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-kf2v-plus",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit-plus",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1789920000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.1-i2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit-plus-2025-10-30",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-i2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-image",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-t2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-t2v-plus",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-t2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "z-image-turbo",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-max-2025-12-30",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-r2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-animate-move",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit-max-2026-01-16",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-i2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.5-t2v-preview",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-t2v-plus",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.0-t2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.0-r2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.5-t2i-preview",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1790092800000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0-pro-2026-06-22",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-i2v-turbo",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-t2v-turbo",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-t2i-turbo",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.0-video-edit",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1789920000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.1-r2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-i2v-2026-04-25",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1789920000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.1-t2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.5-i2v-preview",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-t2i-flash",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-i2v-plus",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-t2i-plus",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-i2v-flash",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1790697600000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-t2v-2026-06-12",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1790697600000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-r2v-2026-06-12",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0-2026-03-03",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit-max",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-max",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0-pro-2026-03-03",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.5-i2i-preview",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-plus",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0-pro",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-plus-2026-01-09",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-i2v-plus",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-image-pro",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-r2v",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 200,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.1-t2i-plus",
"quotaTotal": 200,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.2-animate-mix",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-t2i",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-2.0-pro-2026-04-22",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-i2v-flash",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-t2v-2026-04-25",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 100,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "qwen-image-edit-plus-2025-12-15",
"quotaTotal": 100,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.6-r2v-flash",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 10,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "happyhorse-1.0-i2v",
"quotaTotal": 10,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 50,
"quotaValidityPeriod": 1786896000000,
"freeTierOnly": false,
"quotaTotalPercentage": 100,
"model": "wan2.7-image",
"quotaTotal": 50,
"quotaStatus": "VALID"
},
{
"quotaInitTotal": 0,
"freeTierOnly": false,
"quotaTotalPercentage": 0,
"model": "qwen-image-3.0-pro",
"quotaTotal": 0,
"quotaStatus": "VALID"
}
]
},
"success": true
}
},
"success": true
}
}

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* scripts/ops/deploy-canary.mjs — ship a packaged artifact to a canary host and PROVE it works.
*
* Replaces the manual build → pack → scp → `npm i -g` → `pm2 restart` sequence that caused
* the 2026-08-14 gateway outage (#10429): the package installed there had been built from a
* feature branch predating #10373, the process came up healthy, and every request returned
* `502 … Executor result must contain a Response` until a human noticed.
*
* The policy lives in `deployCanary.ts` (pure, unit-tested); this file is the thin shell
* that performs the side effects and rolls back when the smoke fails.
*
* Usage:
* node scripts/ops/deploy-canary.mjs --host root@192.168.0.17 --tarball ./omniroute-3.8.50.tgz \
* --base-url http://192.168.0.17:20128 --model cx/gpt-5.6-terra --model qct/deepseek-v4-flash-0731
*
* Flags:
* --host ssh target (required)
* --tarball local tarball produced by `npm run build:release && npm pack` (required)
* --base-url http base of the deployed gateway (required)
* --model completion probe target; repeatable, at least one required
* --pm2-app process-manager app name (default: omniroute)
* --dry-run print the plan and the remote steps, change nothing
*
* Env:
* OMNIROUTE_RELEASE_REF ref to check ancestry against (default origin/main)
* OMNIROUTE_ALLOW_CANARY_BUILD set to 1 to deploy an artifact that is not on the release line
* OMNIROUTE_SMOKE_API_KEY sent as Authorization: Bearer when the gateway requires auth
*/
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import process from "node:process";
import {
buildRemoteSteps,
classifyInstallOutcome,
evaluateSmoke,
planCanaryDeploy,
} from "./deployCanary.ts";
import { makeGitAncestryProbe, readBuildSha } from "../build/buildProvenance.ts";
function parseArgs(argv) {
const args = { models: [], pm2App: "omniroute", dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = argv[i + 1];
if (flag === "--host") args.host = value;
else if (flag === "--tarball") args.tarball = value;
else if (flag === "--base-url") args.baseUrl = value;
else if (flag === "--model") args.models.push(value);
else if (flag === "--pm2-app") args.pm2App = value;
else if (flag === "--dry-run") args.dryRun = true;
}
return args;
}
function fail(message) {
console.error(`\n${message}`);
process.exit(1);
}
function run(step) {
console.log(`\n${step.name}: ${step.description}`);
const [command, ...rest] = step.argv;
return execFileSync(command, rest, { encoding: "utf8" }).trim();
}
/**
* Like `run`, but never throws: returns the exit code plus both streams. Used for the
* install, whose exit code does not decide the outcome (see classifyInstallOutcome) and
* whose stderr must reach the log — it used to be swallowed by execFileSync throwing.
*/
function runCapturing(step) {
console.log(`\n${step.name}: ${step.description}`);
const [command, ...rest] = step.argv;
const result = spawnSync(command, rest, { encoding: "utf8" });
return {
exitCode: result.status ?? 1,
stdout: (result.stdout || "").trim(),
stderr: (result.stderr || "").trim(),
};
}
async function probeHealth(baseUrl) {
try {
const response = await fetch(new URL("/api/monitoring/health", baseUrl), {
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) return { ok: false, buildSha: null };
const body = await response.json();
return {
ok: body?.status === "healthy",
buildSha: body?.system?.buildSha ?? null,
};
} catch {
return { ok: false, buildSha: null };
}
}
async function probeCompletion(baseUrl, model, apiKey) {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
try {
const response = await fetch(new URL("/v1/chat/completions", baseUrl), {
method: "POST",
headers,
body: JSON.stringify({
model,
messages: [{ role: "user", content: "reply with: ok" }],
max_tokens: 16,
}),
signal: AbortSignal.timeout(120_000),
});
// A 2xx alone is not enough: the outage this script exists for returned a body-level
// failure. Require a parseable completion with at least one choice.
const body = await response.json().catch(() => null);
const ok = response.ok && Array.isArray(body?.choices) && body.choices.length > 0;
return { model, ok, status: response.status };
} catch {
return { model, ok: false, status: 0 };
}
}
const args = parseArgs(process.argv.slice(2));
if (!args.host) fail("--host is required");
if (!args.tarball) fail("--tarball is required");
if (!args.baseUrl) fail("--base-url is required");
if (args.models.length === 0) {
fail("at least one --model is required — a health check cannot see a broken egress path");
}
const repoRoot = process.cwd();
const localBuildSha = readBuildSha(repoRoot);
const plan = planCanaryDeploy({
buildSha: localBuildSha,
isAncestorOfRelease: makeGitAncestryProbe(
process.env.OMNIROUTE_RELEASE_REF || "origin/main",
repoRoot
),
allowCanary: process.env.OMNIROUTE_ALLOW_CANARY_BUILD === "1",
});
console.log(`[provenance] ${plan.reason}`);
if (!plan.proceed) fail("refusing to deploy an artifact that cannot be traced to the release line");
const remoteTarball = path.posix.join("/root", path.basename(args.tarball));
const steps = buildRemoteSteps({
host: args.host,
tarballPath: remoteTarball,
pm2App: args.pm2App,
});
if (args.dryRun) {
console.log("\n--dry-run: nothing will be changed. Planned steps:");
console.log(` scp ${args.tarball} ${args.host}:${remoteTarball}`);
for (const step of steps) console.log(` ${step.argv.join(" ")}`);
console.log(` probes: health + ${args.models.join(", ")}`);
process.exit(0);
}
let previousSha = null;
try {
const [capture, install, restart, verify] = steps;
previousSha = run(capture);
console.log(` previous BUILD_SHA: ${previousSha || "(none)"}`);
console.log(`\n▶ upload: ${args.tarball}${args.host}:${remoteTarball}`);
execFileSync("scp", [args.tarball, `${args.host}:${remoteTarball}`], { stdio: "inherit" });
const installResult = runCapturing(install);
const outcome = classifyInstallOutcome({
exitCode: installResult.exitCode,
stderr: installResult.stderr,
installedSha: run(verify),
expectedSha: localBuildSha,
});
if (!outcome.installed) {
if (installResult.stderr) console.error(installResult.stderr);
fail(`install did not land: ${outcome.reason}`);
}
if (outcome.kind === "installed-with-cleanup-failure") {
console.warn(` ⚠️ ${outcome.reason}`);
} else {
console.log(` ${outcome.reason}`);
}
run(restart);
const installedSha = run(verify);
console.log(` installed BUILD_SHA: ${installedSha}`);
// Give the process a moment to bind before probing.
await new Promise((resolve) => setTimeout(resolve, 15_000));
const health = await probeHealth(args.baseUrl);
const completions = [];
for (const model of args.models) {
const probe = await probeCompletion(args.baseUrl, model, process.env.OMNIROUTE_SMOKE_API_KEY);
console.log(` probe ${probe.model}: ${probe.ok ? "ok" : `FAILED (${probe.status})`}`);
completions.push(probe);
}
const verdict = evaluateSmoke({ healthOk: health.ok, completions });
if (!verdict.ok) {
console.error(`\n❌ smoke failed: ${verdict.reason}`);
if (previousSha) {
console.error(
`\n⚠️ ROLLBACK REQUIRED — the previous artifact was ${previousSha}. This script does ` +
"not keep old tarballs, so reinstall that build and restart:\n" +
` ssh ${args.host} npm install -g <tarball-for-${previousSha}> --no-audit --no-fund\n` +
` ssh ${args.host} pm2 restart ${args.pm2App} --update-env`
);
}
process.exit(1);
}
console.log(`\n${verdict.reason}`);
console.log(` deployed BUILD_SHA: ${installedSha}`);
if (health.buildSha && health.buildSha !== installedSha) {
console.warn(
`\n⚠️ health reports buildSha ${health.buildSha} but the package says ${installedSha}` +
"the process may still be serving the old artifact."
);
}
} catch (error) {
fail(`deploy aborted: ${error.message}`);
}

231
scripts/ops/deployCanary.ts Normal file
View File

@@ -0,0 +1,231 @@
/**
* Canary deploy policy (#10429) — pure planning + verdict logic.
*
* Deploying the internal gateway used to be a manual sequence (build → pack → scp →
* `npm i -g` → `pm2 restart`) with nothing recording what landed and nothing proving the
* new build served traffic. On 2026-08-14 that shipped a package built from a feature
* branch predating #10373: the process came up, `/api/monitoring/health` answered
* `healthy`, and every real request returned `502 … Executor result must contain a
* Response` until a human hit it.
*
* Two lessons are encoded here:
* 1. Refuse an artifact that cannot be traced to the release line (reuses #10427).
* 2. A health check is NOT a smoke test. Only a real completion exercises the egress
* path where that outage lived, so the verdict requires at least one.
*
* Everything side-effecting (git, ssh, http) is injected or emitted as data, so the policy
* is unit-testable without a host. The thin CLI that executes these steps lives in
* `scripts/ops/deploy-canary.mjs`.
*/
import { resolveBuildProvenance } from "../build/buildProvenance.ts";
export type CanaryPlanInput = {
buildSha: string;
isAncestorOfRelease: (sha: string) => boolean;
allowCanary: boolean;
};
export type CanaryPlan = {
proceed: boolean;
reason: string;
};
/**
* Decide whether an artifact may be shipped at all. Delegates to the provenance policy so
* the pack gate and the deploy path can never disagree about what "shippable" means.
*/
export function planCanaryDeploy(input: CanaryPlanInput): CanaryPlan {
const provenance = resolveBuildProvenance({
buildSha: input.buildSha,
isAncestorOfRelease: input.isAncestorOfRelease,
allowOverride: input.allowCanary,
});
return { proceed: provenance.ok, reason: provenance.message };
}
export type CompletionProbe = {
model: string;
ok: boolean;
status: number;
};
export type SmokeInput = {
healthOk: boolean;
completions: CompletionProbe[];
};
export type SmokeVerdict = {
ok: boolean;
rollback: boolean;
reason: string;
};
/**
* Grade a deploy. Health first (cheap, and a dead process needs no further probing), then
* every completion probe.
*
* An empty probe list FAILS: "no probe ran" must never read as "everything is fine" —
* that is precisely how a broken egress path stays invisible behind a green health check.
*/
export function evaluateSmoke(input: SmokeInput): SmokeVerdict {
if (!input.healthOk) {
return {
ok: false,
rollback: true,
reason: "health endpoint did not report healthy after restart",
};
}
if (input.completions.length === 0) {
return {
ok: false,
rollback: true,
reason:
"no completion probe ran — a health check alone cannot see a broken egress path (#10429)",
};
}
const failed = input.completions.filter((probe) => !probe.ok);
if (failed.length > 0) {
const detail = failed.map((probe) => `${probe.model}${probe.status}`).join(", ");
return {
ok: false,
rollback: true,
reason: `completion probe failed: ${detail}`,
};
}
return {
ok: true,
rollback: false,
reason: `health + ${input.completions.length} completion probe(s) passed`,
};
}
export type RemoteStep = {
name: string;
/** argv form only — never a shell string, so no value can be interpreted (Hard Rule #13). */
argv: string[];
description: string;
};
export type RemoteStepsInput = {
host: string;
tarballPath: string;
pm2App: string;
};
/**
* The remote sequence, as data. Ordered so the rollback anchor is captured BEFORE the
* install overwrites it, and so the SHA is verified only after the restart has actually
* loaded the new artifact.
*
* Emitted as argv arrays rather than shell strings: the paths and app names come from
* config and CLI flags, and interpolating them into `sh -c` is exactly the pattern Hard
* Rule #13 forbids.
*/
export function buildRemoteSteps(input: RemoteStepsInput): RemoteStep[] {
const { host, tarballPath, pm2App } = input;
const shaPath = "/usr/lib/node_modules/omniroute/dist/BUILD_SHA";
return [
{
name: "capture-current-sha",
argv: ["ssh", host, "cat", shaPath],
description: "record the running BUILD_SHA so a failed smoke can be rolled back",
},
{
name: "install",
argv: ["ssh", host, "npm", "install", "-g", tarballPath, "--no-audit", "--no-fund"],
description: "install the packaged artifact globally",
},
{
name: "restart",
argv: ["ssh", host, "pm2", "restart", pm2App, "--update-env"],
description: "restart the service under its process manager",
},
{
name: "verify-installed-sha",
argv: ["ssh", host, "cat", shaPath],
description: "confirm the running artifact is the one just shipped",
},
];
}
export type InstallOutcomeInput = {
exitCode: number;
stderr: string;
/** BUILD_SHA read back from the installed package AFTER the install ran. */
installedSha: string | null | undefined;
/** BUILD_SHA of the artifact being shipped. */
expectedSha: string;
};
export type InstallOutcome = {
installed: boolean;
kind: "installed" | "installed-with-cleanup-failure" | "failed";
reason: string;
};
/**
* Decide whether the global install actually landed.
*
* The exit code alone is not trustworthy in either direction:
*
* - `npm install -g` on the .17 gateway writes the whole package and *then* fails renaming
* the old tree into its staging directory (`ENOTEMPTY`, exit 217). Treating that as a
* failure aborts the deploy after the artifact is already on disk — which happened twice
* on 2026-08-18, each time leaving the host with new files and an old running process.
* - The 2026-08-14 outage went the other way: the install exited 0 while shipping a package
* built from the wrong branch.
*
* So the SHA on disk decides, and it must match exactly. An absent or unreadable SHA fails
* closed — an artifact that cannot be identified is never attested (same rule as the
* provenance gate).
*/
export function classifyInstallOutcome(input: InstallOutcomeInput): InstallOutcome {
const { exitCode, stderr, installedSha, expectedSha } = input;
const onDisk = (installedSha ?? "").trim();
if (!onDisk) {
return {
installed: false,
kind: "failed",
reason: "no BUILD_SHA could be read from the installed package after the install",
};
}
if (onDisk !== expectedSha) {
return {
installed: false,
kind: "failed",
reason: `installed BUILD_SHA is ${onDisk}, expected ${expectedSha}`,
};
}
if (exitCode === 0) {
return { installed: true, kind: "installed", reason: `installed ${onDisk}` };
}
const staging = orphanStagingDirFromStderr(stderr);
const enotempty = /ENOTEMPTY/.test(stderr);
return {
installed: true,
kind: "installed-with-cleanup-failure",
reason:
`npm exited ${exitCode} but ${onDisk} is on disk — the package installed and npm failed ` +
`during its own cleanup${enotempty ? " (ENOTEMPTY on the staging rename)" : ""}` +
(staging ? `; orphaned staging dir left behind: ${staging}` : ""),
};
}
/**
* The staging directory npm failed to rename into, if it named one. It blocks the NEXT
* install with the same error (npm reuses the name), so the operator has to clear it —
* surfacing the exact path is the whole point. Deliberately not removed automatically:
* this is a path under /usr/lib and a blind `rm -rf` there is not something a deploy
* script should do on its own.
*/
export function orphanStagingDirFromStderr(stderr: string): string | null {
const match = /npm error dest (\/\S*\/\.\S+)/.exec(stderr || "");
return match ? match[1] : null;
}

Some files were not shown because too many files have changed in this diff Show More