From ac2fee73058467f67cb86963f8fd2cd96d1ff16e Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 26 May 2026 13:15:59 +0700 Subject: [PATCH] # v0.4.62 (2026-05-26) ## Fixes - Codex: auto-retry when upstream drops mid-stream (no more hangs) - Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache - MITM: support Antigravity 2.x - Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144) - Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343) - Strip empty Read pages argument in OpenAI-to-Claude translator (#1354) - Forward Gemini output dimensions for embeddings (#1366) - Resolve setState-in-effect errors in dashboard components (#1362) - Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271, #1428) --- CHANGELOG.md | 2 +- open-sse/utils/stream.js | 6 +- package.json | 2 +- src/mitm/antigravityIdeVersion.js | 1 + src/mitm/server.js | 114 +++++++++++++++++++++++++++++- src/shared/services/bootstrap.js | 7 +- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e1a71e..e1c14421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Fixes - Codex: auto-retry when upstream drops mid-stream (no more hangs) - Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache -- MITM: support Antigravity 2.x (updated IDE version detection and DNS/cert flow) +- MITM: support Antigravity 2.x - Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144) - Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343) - Strip empty Read pages argument in OpenAI-to-Claude translator (#1354) diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 42829686..582c0f9c 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -65,10 +65,7 @@ export function createSSEStream(options = {}) { return new TransformStream({ transform(chunk, controller) { - if (!ttftAt) { - ttftAt = Date.now(); - dbg("SSE", `${provider}/${model} | first chunk received | size=${chunk?.byteLength || 0}B`); - } + if (!ttftAt) ttftAt = Date.now(); const text = decoder.decode(chunk, { stream: true }); buffer += text; reqLogger?.appendProviderChunk?.(text); @@ -83,7 +80,6 @@ export function createSSEStream(options = {}) { if (trimmed.startsWith("event:")) { const evt = trimmed.slice(6).trim(); eventTypeCounts[evt] = (eventTypeCounts[evt] || 0) + 1; - if (eventTypeCounts[evt] <= 2) dbg("SSE", `recv event: ${evt} (#${eventTypeCounts[evt]})`); } } diff --git a/package.json b/package.json index 340622f1..271127b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.59", + "version": "0.4.62", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/mitm/antigravityIdeVersion.js b/src/mitm/antigravityIdeVersion.js index c2bb8cce..1b9458d8 100644 --- a/src/mitm/antigravityIdeVersion.js +++ b/src/mitm/antigravityIdeVersion.js @@ -34,6 +34,7 @@ function applyAntigravityIdeVersionOverride(bodyBuffer, headers) { if (!shouldRewriteMetadata(parsed?.metadata)) { return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; } + parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION; const nextBodyBuffer = Buffer.from(JSON.stringify(parsed)); return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION }; diff --git a/src/mitm/server.js b/src/mitm/server.js index cf5b23d9..47b23268 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -1,4 +1,6 @@ const https = require("https"); +const http2 = require("http2"); +const tls = require("tls"); const fs = require("fs"); const path = require("path"); const dns = require("dns"); @@ -133,7 +135,6 @@ async function passthrough(req, res, bodyBuffer, onResponse) { // Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent"); const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost; - const targetIP = await resolveTargetIP(targetHost); const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null; const tool = getToolForHost(req.headers.host); @@ -146,12 +147,121 @@ async function passthrough(req, res, bodyBuffer, onResponse) { headersForForwarding["content-length"] = String(bodyForForwarding.length); } + // ALPN negotiate: try HTTP/2 first (like browsers/mitmweb), fallback HTTP/1.1 + try { + const proto = await negotiateAlpn(targetHost); + if (proto === "h2") { + return await passthroughHttp2(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); + } + } catch (e) { + err(`[mitm] ALPN negotiate failed: ${e.message}, fallback to HTTP/1.1`); + } + + return passthroughHttps(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); +} + +// ── ALPN negotiation cache ──────────────────────────────────── +const alpnCache = new Map(); // host → "h2" | "http/1.1" +async function negotiateAlpn(host) { + if (alpnCache.has(host)) return alpnCache.get(host); + const ip = await resolveTargetIP(host); + return new Promise((resolve, reject) => { + const socket = tls.connect({ + host: ip, port: 443, servername: host, + ALPNProtocols: ["h2", "http/1.1"], rejectUnauthorized: false, + }, () => { + const proto = socket.alpnProtocol || "http/1.1"; + alpnCache.set(host, proto); + log(`🔗 [mitm] ALPN ${host} → ${proto}`); + socket.end(); + resolve(proto); + }); + socket.once("error", reject); + socket.setTimeout(5000, () => { socket.destroy(new Error("ALPN timeout")); }); + }); +} + +// HTTP/2 passthrough using node:http2 native +async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); + // HTTP/2 pseudo-headers required; strip HTTP/1.1-only headers + const h2Headers = {}; + for (const [k, v] of Object.entries(headers)) { + const lk = k.toLowerCase(); + if (lk === "host" || lk === "connection" || lk === "keep-alive" || + lk === "transfer-encoding" || lk === "upgrade" || lk === "proxy-connection") continue; + h2Headers[lk] = v; + } + h2Headers[":method"] = req.method; + h2Headers[":path"] = req.url; + h2Headers[":scheme"] = "https"; + h2Headers[":authority"] = targetHost; + + return new Promise((resolve) => { + const client = http2.connect(`https://${targetHost}`, { + createConnection: () => tls.connect({ + host: targetIP, port: 443, servername: targetHost, + ALPNProtocols: ["h2"], rejectUnauthorized: false, + }), + }); + client.once("error", (e) => { + err(`[mitm] http2 client error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end("Bad Gateway"); + try { client.close(); } catch {} + resolve(); + }); + + const stream = client.request(h2Headers, { endStream: bodyBuffer.length === 0 }); + if (bodyBuffer.length > 0) stream.end(bodyBuffer); + + stream.once("response", (responseHeaders) => { + const status = responseHeaders[":status"]; + // Filter pseudo-headers + connection-specific + const outHeaders = {}; + for (const [k, v] of Object.entries(responseHeaders)) { + if (k.startsWith(":")) continue; + if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") continue; + outHeaders[k] = v; + } + res.writeHead(status, outHeaders); + if (dumper) dumper.writeHeader(status, outHeaders); + + const chunks = []; + stream.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); + stream.on("end", () => { + if (dumper) dumper.end(); + if (!res.writableEnded) res.end(); + if (onResponse) try { onResponse(Buffer.concat(chunks), outHeaders); } catch {} + try { client.close(); } catch {} + resolve(); + }); + }); + stream.once("error", (e) => { + err(`[mitm] http2 stream error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2-stream] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end(); + try { client.close(); } catch {} + resolve(); + }); + }); +} + +// Fallback: raw https.request HTTP/1.1 with custom DNS (bypasses /etc/hosts MITM loop) +async function passthroughHttps(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); const forwardReq = https.request({ hostname: targetIP, port: 443, path: req.url, method: req.method, - headers: headersForForwarding, + headers, servername: targetHost, rejectUnauthorized: false }, (forwardRes) => { diff --git a/src/shared/services/bootstrap.js b/src/shared/services/bootstrap.js index 8080c3b2..a4e03da9 100644 --- a/src/shared/services/bootstrap.js +++ b/src/shared/services/bootstrap.js @@ -1,7 +1,12 @@ import initializeApp from "./initializeApp.js"; +// Skip during Next.js build/prerender — bootstrap would download cloudflared, init DNS, etc. +const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build" + || process.env.NEXT_PHASE === "phase-export" + || process.env.NEXT_PHASE === "phase-static"; + // Server-only singleton: guard via global so HMR / re-imports don't double-init -if (typeof window === "undefined" && !global.__appBootstrapped) { +if (typeof window === "undefined" && !isBuildPhase && !global.__appBootstrapped) { global.__appBootstrapped = true; initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message)); }