Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
c3bf4053e4 fix(providers): scope TinyCMS DOM shims to the call, not the process (#12072)
initTinyCmsWasm()/generateSecurePayload() installed global.window/document
DOM shims via setupDomMocks() but never called the returned restore
callback. On an npm-global install the Next.js dashboard SSR runs in the
same Node process, so after the first TinyCMS request every later SSR
render observed the leaked fake document, whose createElement() returns
null for anything but 'canvas' — turning the next render into a 500.

Wrap both call sites in install -> use -> restore (try/finally) so the
shims are scoped to just the call instead of the process lifetime.

Also fixes a second, smaller bug found while tracing the client-visible
symptom: fetchInterceptionToggles() called res.json() without checking
res.ok first, so a non-JSON error body (e.g. the plain-text 500 above)
surfaced as a raw SyntaxError in the interceptionLoadError toast instead
of a clean HTTP <status> message.
2026-09-10 14:49:29 -03:00
12 changed files with 201 additions and 141 deletions

View File

@@ -0,0 +1 @@
- fix(providers): scope TinyCMS Web signer's DOM shims to each call instead of leaking them for the process lifetime, and surface a clean HTTP status on a non-JSON interception-toggles error (#12072)

View File

@@ -1 +0,0 @@
- fix(providers): send `x-api-key` instead of `Authorization: Bearer` for OpenCode Zen's `/v1/responses` endpoint (Muse Spark Contributor models), fixing a 401 on OmniRoute's auth header (#12633)

View File

@@ -1 +0,0 @@
- fix(models): declare the real ~1M contextLength for OpenCode Zen's Muse Spark 1.2 models instead of falling back to the 200000 provider default (#12681)

View File

@@ -30,25 +30,17 @@ export const opencodeProvider: RegistryEntry = {
// content (see issue #10867). The opencode provider is passthrough, so
// declaring them here only sets the wire format / capability flags — the
// live upstream model list already advertises both ids.
// #12681: real window confirmed against the opencode-go registry's own
// muse-spark-1.2-contributor entries (contextLength: 1048576, maxOutputTokens:
// 131072) — without an explicit value here resolution fell back to the
// provider-wide defaultContextLength (200000), understating the real window.
{
id: "muse-spark-1.2",
name: "Muse Spark 1.2",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{
id: "muse-spark-1.2-contributor-free",
name: "Muse Spark 1.2 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true },
// #6998: 2026-07-14 refresh — the upstream free tier rotated its lineup;

View File

@@ -63,17 +63,11 @@ export const opencode_zenProvider: RegistryEntry = {
// targetFormat declaration, so requests routed here still hit
// /chat/completions with a mismatched or unanswerable body and the
// upstream returns an empty message.
// #12681: real window confirmed against the opencode-go registry's own
// muse-spark-1.2-contributor entries (contextLength: 1048576, maxOutputTokens:
// 131072) — without an explicit value here resolution fell back to the
// provider-wide defaultContextLength (200000), understating the real window.
{
id: "muse-spark-1.2",
name: "Muse Spark 1.2",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
// Explicit wire-format overlay of the base opencode provider's muse-spark entry
// (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
@@ -82,8 +76,6 @@ export const opencode_zenProvider: RegistryEntry = {
name: "Muse Spark 1.2 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
// ── DeepSeek ────────────────────────────────────────────────

View File

@@ -31,13 +31,6 @@ import {
import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts";
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
/**
* The main OpenCode Zen host, shared by the `opencode` and `opencode-zen`
* registry entries. Used to scope the `x-api-key` auth override (#12633) away
* from `opencode-go`, which serves a different upstream (`.../zen/go/v1`).
*/
const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
/**
* Per-account proxy configuration, persisted by NoAuthAccountCard under
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
@@ -783,20 +776,6 @@ export class OpencodeExecutor extends BaseExecutor {
}
}
/**
* #12633: OpenCode Zen's `/v1/responses` endpoint (reached when
* `_requestFormat === "openai-responses"`, e.g. Muse Spark Contributor
* models) requires `x-api-key`, not `Authorization: Bearer` — unlike the
* default `/chat/completions` endpoint on the same host, which accepts
* Bearer. Scoped by baseUrl (not provider id/alias) so this only applies to
* the main Zen host (`opencode` / `opencode-zen`, both `https://opencode.ai/zen/v1`)
* and never to opencode-go, which serves Responses-format models from a
* different upstream (`https://opencode.ai/zen/go/v1`) that expects Bearer.
*/
private usesZenApiKeyAuth(): boolean {
return this._requestFormat === "openai-responses" && this.config?.baseUrl === ZEN_BASE_URL;
}
buildHeaders(
credentials: ProviderCredentials | null,
stream = true,
@@ -813,7 +792,7 @@ export class OpencodeExecutor extends BaseExecutor {
: undefined;
if (key) {
if (this._requestFormat === "claude" || this.usesZenApiKeyAuth()) {
if (this._requestFormat === "claude") {
headers["x-api-key"] = key;
} else {
headers["Authorization"] = `Bearer ${key}`;

View File

@@ -467,13 +467,21 @@ let wasmInitialized = false;
export async function initTinyCmsWasm() {
if (wasmInitialized) return;
// Install the DOM shims the wasm-bindgen glue expects before instantiating
// the module (see setupDomMocks() above). Left installed for the process
// lifetime — generateSecurePayload() keeps calling into the same canvas
// shims on every invocation, not just at init.
setupDomMocks();
const wasmBuffer = Buffer.from(WASM_BASE64, 'base64');
await __wbg_init(wasmBuffer);
wasmInitialized = true;
// the module (see setupDomMocks() above), and restore them right after —
// scoped to just this init call instead of the process lifetime. This
// process runs the Next.js dashboard SSR too (npm-global install), so
// leaving global.window/document installed here would poison every later
// SSR render (#12072). generateSecurePayload() below re-installs its own
// shims around each call, since the wasm-bindgen glue reaches back into
// document.createElement/getContext on every invocation, not just at init.
const restore = setupDomMocks();
try {
const wasmBuffer = Buffer.from(WASM_BASE64, 'base64');
await __wbg_init(wasmBuffer);
wasmInitialized = true;
} finally {
restore();
}
}
// Add type bindings
@@ -501,5 +509,15 @@ export function generateSecurePayload(
client_ip: string,
difficulty: number
): SecurePayload {
return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload;
// Scope the DOM shims to just this synchronous call (install -> use ->
// restore) instead of relying on whatever initTinyCmsWasm() left behind
// — that call now restores its own shims immediately, and this is fully
// synchronous (no await between install and restore), so nothing else on
// Node's single-threaded event loop can observe the shim in between.
const restore = setupDomMocks();
try {
return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload;
} finally {
restore();
}
}

View File

@@ -32,8 +32,15 @@ type Translate = (key: string, values?: Record<string, string>) => string;
const DEFAULT_TOGGLES: InterceptionToggles = { interceptSearch: false, interceptFetch: false };
async function throwOnErrorResponse(res: Response): Promise<void> {
if (res.ok) return;
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
async function fetchInterceptionToggles(providerId: string): Promise<InterceptionToggles> {
const res = await fetch(`/api/providers/${providerId}/interception-rules`);
await throwOnErrorResponse(res);
const data = await res.json();
return {
interceptSearch: data?.interceptSearch === true,
@@ -41,12 +48,6 @@ async function fetchInterceptionToggles(providerId: string): Promise<Interceptio
};
}
async function throwOnErrorResponse(res: Response): Promise<void> {
if (res.ok) return;
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
async function putInterceptionToggles(
providerId: string,
toggles: InterceptionToggles

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// Regression test for issue #12072 (second, smaller bug found while fixing
// the TinyCMS DOM-shim leak): fetchInterceptionToggles() used to call
// `await res.json()` without checking `res.ok` first, so a non-JSON error
// body (e.g. a plain-text 500 from the poisoned-SSR bug) surfaced as a raw
// `SyntaxError` inside the `interceptionLoadError` toast instead of a clean
// `HTTP <status>` message.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ProviderInterceptionSection from "../ProviderInterceptionSection";
// Stable references: the component's load effect depends on `t` and `notify`,
// so a mock returning a fresh closure/object on every render would re-fire the
// effect after every setState (an infinite loop) instead of running once.
const stableTranslate = (key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key;
vi.mock("next-intl", () => ({
useTranslations: () => stableTranslate,
}));
const notifyError = vi.fn();
const stableNotify = { error: notifyError, success: vi.fn() };
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => stableNotify,
}));
const cleanups: Array<() => void> = [];
function renderComponent(node: React.ReactElement) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
cleanups.push(() => {
act(() => root.unmount());
container.remove();
});
return container;
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("ProviderInterceptionSection (#12072)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
notifyError.mockClear();
});
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("surfaces a clean HTTP status message when GET returns a non-JSON 500 body", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
json: () => Promise.reject(new SyntaxError('Unexpected token \'I\', "Internal S"...')),
} as unknown as Response)
)
);
renderComponent(<ProviderInterceptionSection providerId="openai" />);
await flush();
expect(notifyError).toHaveBeenCalledTimes(1);
const [message] = notifyError.mock.calls[0] as [string];
expect(message).toContain("HTTP 500");
expect(message).not.toContain("Unexpected token");
expect(message).not.toContain("SyntaxError");
});
it("loads toggles normally when GET returns a valid JSON body", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ interceptSearch: true, interceptFetch: false }),
} as unknown as Response)
)
);
renderComponent(<ProviderInterceptionSection providerId="openai" />);
await flush();
expect(notifyError).not.toHaveBeenCalled();
});
});

View File

@@ -1,54 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
test("#12633: openai-responses format on opencode-zen sends x-api-key, not Authorization Bearer", () => {
const executor = new OpencodeExecutor("opencode-zen");
executor._requestFormat = "openai-responses";
const headers = executor.buildHeaders(
{ apiKey: "sk-zen-test" },
true,
null,
"muse-spark-1.2-contributor-free"
);
assert.equal(headers["x-api-key"], "sk-zen-test");
assert.equal(headers["Authorization"], undefined);
});
test("#12633: openai-responses format on the base opencode (oc) provider also sends x-api-key", () => {
const executor = new OpencodeExecutor("opencode");
executor._requestFormat = "openai-responses";
const headers = executor.buildHeaders(
{ apiKey: "sk-oc-test" },
true,
null,
"muse-spark-1.2-contributor-free"
);
assert.equal(headers["x-api-key"], "sk-oc-test");
assert.equal(headers["Authorization"], undefined);
});
test("#12633: openai-responses format on opencode-go (different upstream endpoint) keeps Authorization Bearer", () => {
const executor = new OpencodeExecutor("opencode-go");
executor._requestFormat = "openai-responses";
const headers = executor.buildHeaders(
{ apiKey: "sk-go-test" },
true,
null,
"muse-spark-1.2-contributor"
);
assert.equal(headers["Authorization"], "Bearer sk-go-test");
assert.equal(headers["x-api-key"], undefined);
});
test("#12633: claude format keeps sending x-api-key (unchanged behavior)", () => {
const executor = new OpencodeExecutor("opencode-zen");
executor._requestFormat = "claude";
const headers = executor.buildHeaders({ apiKey: "sk-claude-test" }, true, null, "some-model");
assert.equal(headers["x-api-key"], "sk-claude-test");
assert.equal(headers["Authorization"], undefined);
});

View File

@@ -1,33 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
import { getTokenLimit } from "../../open-sse/services/contextManager.ts";
test("#12681: opencode registry declares an explicit real contextLength for muse-spark-1.2 models", () => {
const opencode = REGISTRY["opencode"];
const museSpark = opencode.models.find((m) => m.id === "muse-spark-1.2");
const museSparkFree = opencode.models.find((m) => m.id === "muse-spark-1.2-contributor-free");
assert.notEqual(
museSpark?.contextLength,
undefined,
"muse-spark-1.2 should declare its own real contextLength instead of relying on the 200000 provider default"
);
assert.notEqual(
museSparkFree?.contextLength,
undefined,
"muse-spark-1.2-contributor-free should declare its own real contextLength instead of relying on the 200000 provider default"
);
});
test("#12681: opencode-zen registry declares an explicit real contextLength for muse-spark-1.2 models", () => {
const zen = REGISTRY["opencode-zen"];
const museSpark = zen.models.find((m) => m.id === "muse-spark-1.2");
const museSparkFree = zen.models.find((m) => m.id === "muse-spark-1.2-contributor-free");
assert.notEqual(museSpark?.contextLength, undefined);
assert.notEqual(museSparkFree?.contextLength, undefined);
});
test("#12681: contextManager.getTokenLimit resolves muse-spark-1.2-contributor-free to its real 1M+ window, not the 200000 provider default", () => {
assert.equal(getTokenLimit("opencode", "muse-spark-1.2-contributor-free"), 1048576);
assert.equal(getTokenLimit("opencode-zen", "muse-spark-1.2-contributor-free"), 1048576);
});

View File

@@ -0,0 +1,63 @@
/**
* Regression test for issue #12072.
*
* initTinyCmsWasm() / generateSecurePayload() used to call setupDomMocks()
* and never invoke the restore callback it returns, so global.window /
* global.document / HTMLCanvasElement remained installed on the Node
* process for its entire lifetime. On an npm-global install the Next.js
* dashboard SSR runs in that same process, so after the first TinyCMS
* request every SSR render observed a fake `document` whose
* createElement() returns null for anything but 'canvas' — which turned
* the following SSR render into a plain-text 500.
*
* This test proves the shims are scoped to the call (installed, used,
* restored) instead of leaking past it, directly against
* tinycmsSigner.ts, without needing a live TinyCMS network call or a
* running Next.js server.
*/
import test from "node:test";
import assert from "node:assert/strict";
test("initTinyCmsWasm does not leave global.window/document installed after it resolves", async () => {
const g = global as Record<string, unknown>;
// Sanity: nothing must be present before we start, otherwise the
// assertions below prove nothing.
assert.equal("window" in g, false, "test process must not already have global.window");
assert.equal("document" in g, false, "test process must not already have global.document");
const { initTinyCmsWasm } = await import("../../open-sse/executors/tinycmsSigner.ts");
await initTinyCmsWasm();
assert.equal(
typeof g.window,
"undefined",
"REGRESSION (#12072): global.window leaked past initTinyCmsWasm() — this is what makes " +
"`typeof window !== \"undefined\"` true for every subsequent SSR render in the same process"
);
assert.equal(
typeof g.document,
"undefined",
"REGRESSION (#12072): global.document leaked past initTinyCmsWasm()"
);
});
test("generateSecurePayload does not leave global.window/document installed after it returns", async () => {
const g = global as Record<string, unknown>;
const { generateSecurePayload } = await import("../../open-sse/executors/tinycmsSigner.ts");
generateSecurePayload("user", String(Date.now()), "nonce", "challenge", "127.0.0.1", 1);
assert.equal(
typeof g.window,
"undefined",
"REGRESSION (#12072): global.window leaked past generateSecurePayload()"
);
assert.equal(
typeof g.document,
"undefined",
"REGRESSION (#12072): global.document leaked past generateSecurePayload()"
);
});