mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
This commit is contained in:
committed by
GitHub
parent
318a5e5220
commit
f8e179d479
@@ -10,6 +10,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`.
|
||||
- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one).
|
||||
- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`.
|
||||
- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127)
|
||||
|
||||
@@ -5,7 +5,10 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { cloudflareDeploySchema } from "@/shared/validation/freeProxySchemas";
|
||||
import { createProxy } from "@/lib/localDb";
|
||||
import { encrypt } from "@/lib/db/encryption";
|
||||
import { buildCloudflareWorkerScript } from "@/lib/proxyRelay/cloudflareWorkerScript";
|
||||
import {
|
||||
buildCloudflareWorkerScript,
|
||||
buildCloudflareWorkerUploadRequest,
|
||||
} from "@/lib/proxyRelay/cloudflareWorkerScript";
|
||||
|
||||
// Port of upstream decolua/9router PR #1360 — Cloudflare Workers proxy relay.
|
||||
// Architecture mirrors src/app/api/settings/proxy/vercel-deploy/route.ts so the
|
||||
@@ -50,36 +53,33 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
// 1. PUT the Worker script — Cloudflare requires multipart/form-data with
|
||||
// main_module + a metadata blob describing the upload.
|
||||
//
|
||||
// Built as a raw Buffer with an explicit boundary rather than a native
|
||||
// `FormData` (#6416): in production `globalThis.fetch` is patched with
|
||||
// `node_modules/undici`'s own fetch (see `open-sse/utils/proxyFetch.ts`),
|
||||
// whose `FormData` class differs from the runtime's global `FormData`.
|
||||
// Passing a native `FormData` instance through that patched fetch makes
|
||||
// undici serialize the body as the literal string `"[object FormData]"`
|
||||
// with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare
|
||||
// rejects with "Content-Type must be one of: application/javascript,
|
||||
// text/javascript, multipart/form-data" — the same class of bug fixed
|
||||
// for image edits in #3273. ES-module semantics come from `main_module`
|
||||
// in the metadata part below, not the script part's Content-Type
|
||||
// (Cloudflare rejects "application/javascript+module" outright, #5128).
|
||||
const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`;
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"index.js",
|
||||
// Cloudflare's script-upload API only accepts application/javascript,
|
||||
// text/javascript, or multipart/form-data for the script part and rejects
|
||||
// "application/javascript+module" outright (#5128). ES-module semantics
|
||||
// come from `main_module` in the metadata blob below, not this MIME type.
|
||||
new Blob([workerScript], { type: "application/javascript" }),
|
||||
"index.js"
|
||||
);
|
||||
formData.append(
|
||||
"metadata",
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
main_module: "index.js",
|
||||
compatibility_date: "2026-03-20",
|
||||
observability: { enabled: true },
|
||||
}),
|
||||
],
|
||||
{ type: "application/json" }
|
||||
),
|
||||
"metadata.json"
|
||||
const { headers: uploadHeaders, body: uploadBody } = buildCloudflareWorkerUploadRequest(
|
||||
workerScript,
|
||||
{
|
||||
main_module: "index.js",
|
||||
compatibility_date: "2026-03-20",
|
||||
observability: { enabled: true },
|
||||
}
|
||||
);
|
||||
|
||||
const uploadRes = await fetch(workerScriptUrl, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: formData,
|
||||
headers: { Authorization: `Bearer ${apiToken}`, ...uploadHeaders },
|
||||
body: uploadBody,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
|
||||
@@ -22,6 +22,54 @@
|
||||
* the same proxyFetch relay short-circuit work unchanged.
|
||||
* - SSRF guard is inlined so a leaked relay URL cannot scan internal IPs.
|
||||
*/
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/**
|
||||
* Build the multipart/form-data request body for Cloudflare's Worker
|
||||
* script-upload API as a raw `Buffer` with an explicit boundary, instead of
|
||||
* relying on the WHATWG `FormData` + `fetch`-derived Content-Type (#6416).
|
||||
*
|
||||
* In production `globalThis.fetch` is patched (`open-sse/utils/proxyFetch.ts`)
|
||||
* with `node_modules/undici`'s own `fetch`, whose `FormData`/`Request` classes
|
||||
* differ from the runtime's global `FormData` (same cross-realm class
|
||||
* mismatch already fixed for image edits in #3273 —
|
||||
* `open-sse/handlers/imageGeneration.ts::handleOpenAIImageEdit`). Passing a
|
||||
* native `FormData` instance through that patched fetch makes undici
|
||||
* serialize the body as the literal string `"[object FormData]"` with
|
||||
* `Content-Type: text/plain;charset=UTF-8` — which Cloudflare's API rejects
|
||||
* with "Content-Type must be one of: application/javascript, text/javascript,
|
||||
* multipart/form-data". A manually-built `Buffer` body with an explicit
|
||||
* `multipart/form-data; boundary=…` header is accepted verbatim by any fetch
|
||||
* implementation (patched or native).
|
||||
*/
|
||||
export function buildCloudflareWorkerUploadRequest(
|
||||
workerScript: string,
|
||||
metadata: Record<string, unknown>
|
||||
): { headers: Record<string, string>; body: Buffer } {
|
||||
const boundary = `----OmniRouteCFWorker${randomUUID().replace(/-/g, "")}`;
|
||||
const CRLF = "\r\n";
|
||||
const parts: Buffer[] = [
|
||||
Buffer.from(
|
||||
`--${boundary}${CRLF}` +
|
||||
`Content-Disposition: form-data; name="index.js"; filename="index.js"${CRLF}` +
|
||||
`Content-Type: application/javascript${CRLF}${CRLF}`
|
||||
),
|
||||
Buffer.from(workerScript, "utf8"),
|
||||
Buffer.from(CRLF),
|
||||
Buffer.from(
|
||||
`--${boundary}${CRLF}` +
|
||||
`Content-Disposition: form-data; name="metadata"; filename="metadata.json"${CRLF}` +
|
||||
`Content-Type: application/json${CRLF}${CRLF}` +
|
||||
`${JSON.stringify(metadata)}${CRLF}`
|
||||
),
|
||||
Buffer.from(`--${boundary}--${CRLF}`),
|
||||
];
|
||||
return {
|
||||
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: Buffer.concat(parts),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCloudflareWorkerScript(relayAuth: string): string {
|
||||
// relayAuth is generated server-side via randomBytes(24).toString("hex") — no
|
||||
// user-controlled input ever reaches this template, so direct interpolation
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Request as UndiciRequest } from "undici";
|
||||
|
||||
import { buildCloudflareWorkerUploadRequest } from "../../src/lib/proxyRelay/cloudflareWorkerScript.ts";
|
||||
|
||||
// Issue #6416: deploying a Cloudflare relay Worker from Dashboard → System →
|
||||
// Proxy pool → Cloudflare relay failed immediately with:
|
||||
// "Cloudflare Worker upload failed: Content-Type must be one of:
|
||||
// application/javascript, text/javascript, multipart/form-data"
|
||||
// even with a valid token/account (not a credential problem).
|
||||
//
|
||||
// Root cause: the upload built a native `FormData` and let `fetch` derive the
|
||||
// multipart Content-Type automatically. In production `globalThis.fetch` is
|
||||
// patched (open-sse/utils/proxyFetch.ts) with `node_modules/undici`'s own
|
||||
// fetch, whose `FormData`/`Request` classes differ from the runtime's global
|
||||
// `FormData` — a cross-realm class mismatch. Passing a native `FormData`
|
||||
// instance through undici's `Request`/`fetch` makes it fail to recognize the
|
||||
// body as FormData and serialize it as the literal string `"[object
|
||||
// FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare
|
||||
// rejects outright. This is the same class of bug already fixed once for
|
||||
// image edits in #3273 (open-sse/handlers/imageGeneration.ts).
|
||||
|
||||
test("RED reproduction: a native FormData body loses its shape through undici's Request/fetch", () => {
|
||||
// This documents the underlying cross-realm bug itself (independent of our
|
||||
// fix) — it does not touch application code, just proves the mechanism
|
||||
// that made the original bug report reproducible on self-hosted Docker
|
||||
// (where globalThis.fetch is always patched with this same `undici` pkg).
|
||||
const fd = new FormData();
|
||||
fd.append(
|
||||
"index.js",
|
||||
new Blob(["export default { fetch() {} };"], { type: "application/javascript" }),
|
||||
"index.js"
|
||||
);
|
||||
|
||||
const req = new UndiciRequest("https://api.cloudflare.com/client/v4/accounts/x/workers/scripts/y", {
|
||||
method: "PUT",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
// This is the exact Content-Type Cloudflare's API rejects — proves *why*
|
||||
// relying on FormData + fetch-derived headers broke on self-hosted Docker.
|
||||
assert.equal(req.headers.get("content-type"), "text/plain;charset=UTF-8");
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerUploadRequest sets a Cloudflare-accepted Content-Type", () => {
|
||||
const { headers } = buildCloudflareWorkerUploadRequest("export default { fetch() {} };", {
|
||||
main_module: "index.js",
|
||||
compatibility_date: "2026-03-20",
|
||||
});
|
||||
|
||||
const contentType = headers["Content-Type"];
|
||||
assert.ok(contentType, "Content-Type header must be set explicitly");
|
||||
assert.match(contentType, /^multipart\/form-data; boundary=.+/);
|
||||
assert.notEqual(contentType, "application/json");
|
||||
assert.notEqual(contentType, "text/plain;charset=UTF-8");
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerUploadRequest body is a well-formed multipart Buffer carrying both parts", () => {
|
||||
const workerScript = "export default { fetch() { return new Response('ok'); } };";
|
||||
const metadata = { main_module: "index.js", compatibility_date: "2026-03-20" };
|
||||
const { headers, body } = buildCloudflareWorkerUploadRequest(workerScript, metadata);
|
||||
|
||||
assert.ok(Buffer.isBuffer(body), "body must be a Buffer, not a FormData instance");
|
||||
const text = body.toString("utf8");
|
||||
const boundary = headers["Content-Type"].split("boundary=")[1];
|
||||
|
||||
assert.ok(text.includes(`--${boundary}`), "body must contain the declared boundary");
|
||||
assert.ok(
|
||||
text.includes('Content-Disposition: form-data; name="index.js"; filename="index.js"'),
|
||||
"body must carry the index.js script part"
|
||||
);
|
||||
assert.ok(
|
||||
text.includes('Content-Disposition: form-data; name="metadata"; filename="metadata.json"'),
|
||||
"body must carry the metadata part"
|
||||
);
|
||||
assert.ok(text.includes(workerScript), "body must embed the actual worker script source");
|
||||
assert.ok(text.includes(JSON.stringify(metadata)), "body must embed the JSON metadata");
|
||||
assert.ok(!text.includes("[object FormData]"), "body must never degrade to the FormData stringification bug");
|
||||
});
|
||||
|
||||
test("the request undici's fetch/Request builds from our headers+body keeps the accepted Content-Type", () => {
|
||||
// Simulates exactly what open-sse/utils/proxyFetch.ts's patchedFetch does in
|
||||
// production (self-hosted, non-cloud): constructing a Request/fetch call
|
||||
// via the pinned `undici` package. This is the regression guard for the
|
||||
// actual upload path used by the cloudflare-deploy route.
|
||||
const { headers, body } = buildCloudflareWorkerUploadRequest("export default {};", {
|
||||
main_module: "index.js",
|
||||
});
|
||||
|
||||
const req = new UndiciRequest("https://api.cloudflare.com/client/v4/accounts/x/workers/scripts/y", {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
const contentType = req.headers.get("content-type");
|
||||
assert.ok(contentType?.startsWith("multipart/form-data; boundary="));
|
||||
});
|
||||
@@ -81,13 +81,24 @@ test("#5128B: extractRelayAuth decrypts the encrypted relayAuthEnc form (encrypt
|
||||
// --------------------------------------------------------------------------
|
||||
test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let putBodyType: string | undefined;
|
||||
let requestContentType: string | undefined;
|
||||
let scriptPartContentType: string | undefined;
|
||||
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
if (init.method === "PUT" && url.includes("/workers/scripts/")) {
|
||||
const fd = init.body as FormData;
|
||||
const scriptPart = fd.get("index.js") as Blob;
|
||||
putBodyType = scriptPart.type;
|
||||
// #6416: the upload body is a raw multipart Buffer (not a FormData
|
||||
// instance — see cloudflareWorkerScript.ts::buildCloudflareWorkerUploadRequest),
|
||||
// so assert directly on the request Content-Type header and the
|
||||
// embedded part header instead of reading FormData.get().
|
||||
const headers = new Headers(init.headers);
|
||||
requestContentType = headers.get("content-type") ?? undefined;
|
||||
const bodyText = Buffer.isBuffer(init.body)
|
||||
? (init.body as Buffer).toString("utf8")
|
||||
: String(init.body);
|
||||
const match = bodyText.match(
|
||||
/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/
|
||||
);
|
||||
scriptPartContentType = match?.[1];
|
||||
// Simulate the CF API rejecting the upload so the route short-circuits
|
||||
// without making the follow-up subdomain calls.
|
||||
return Response.json({ errors: [{ message: "stubbed" }] }, { status: 400 });
|
||||
@@ -112,10 +123,18 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
|
||||
// #6416: the overall request Content-Type must itself be one CF accepts —
|
||||
// a FormData body auto-negotiated through undici's patched fetch degraded
|
||||
// to "text/plain;charset=UTF-8", which CF flatly rejects.
|
||||
assert.ok(
|
||||
requestContentType?.startsWith("multipart/form-data; boundary="),
|
||||
`expected an accepted request Content-Type, got "${requestContentType}"`
|
||||
);
|
||||
// CF rejects "application/javascript+module"; only these are accepted.
|
||||
assert.ok(
|
||||
putBodyType === "application/javascript" || putBodyType === "text/javascript",
|
||||
`expected an accepted script MIME, got "${putBodyType}"`
|
||||
scriptPartContentType === "application/javascript" ||
|
||||
scriptPartContentType === "text/javascript",
|
||||
`expected an accepted script MIME, got "${scriptPartContentType}"`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user