diff --git a/CHANGELOG.md b/CHANGELOG.md index ac20501b..91e1a71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ -# Unreleased +# v0.4.62 (2026-05-26) ## Fixes -- Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271) +- 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) +- 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) + +## Features +- Add Cloudflare Workers proxy deployer and pool integration (#1360) +- Add Deno Deploy relays support and improved proxy pools dashboard layout (#1437) + +## Improvements +- Refactor Tunnel into dedicated Cloudflare and Tailscale manager modules +- Refactor tokenRefresh service with in-flight dedup to prevent refresh_token_reused errors # v0.4.59 (2026-05-21) diff --git a/cli/package.json b/cli/package.json index 2eae0237..52adf7f5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.59", + "version": "0.4.62", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index c2a4888e..4fe8ebf3 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -35,7 +35,7 @@ export const MEMORY_CONFIG = { export const STREAM_STALL_TIMEOUT_MS = 60 * 1000; // Fetch connect timeout: abort if upstream doesn't return response headers within this duration -export const FETCH_CONNECT_TIMEOUT_MS = 30 * 1000; +export const FETCH_CONNECT_TIMEOUT_MS = 20 * 1000; // Default token limits export const DEFAULT_MAX_TOKENS = 64000; diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index 16cac731..1d62bcb9 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -1,4 +1,4 @@ -import { Readable } from "stream"; +// import { Readable } from "stream"; // unused — re-enable with got-scraping block below import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; import { dbg } from "./debugLog.js"; @@ -6,8 +6,9 @@ const originalFetch = globalThis.fetch; const proxyDispatchers = new Map(); // ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── -// Lazy-loaded once; if import fails (missing optional native deps in some -// envs) we silently fall back to native fetch — no behavioral change. +// Disabled: not in use. Kept commented for future re-enable. +// Restore the original block to re-enable per-host JA3 spoofing. +/* let _gotScraping = null; let _gotScrapingChecked = false; const _gotScrapingLoggedHosts = new Set(); @@ -26,7 +27,6 @@ async function getGotScraping() { return _gotScraping; } -// Run a request through got-scraping streaming, return a fetch-compatible Response async function gotScrapingFetch(url, options) { const gs = await getGotScraping(); if (!gs) return null; @@ -46,13 +46,13 @@ async function gotScrapingFetch(url, options) { body: method === "GET" || method === "HEAD" ? undefined : options.body, throwHttpErrors: false, retry: { limit: 0 }, - timeout: { request: undefined }, // streaming → no overall timeout + timeout: { request: undefined }, followRedirect: false, decompress: true, }); if (options.signal) { - const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { /* noop */ } }; + const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; if (options.signal.aborted) onAbort(); else options.signal.addEventListener("abort", onAbort, { once: true }); } @@ -87,7 +87,7 @@ async function tryGotScrapingFetch(url, options) { _gotScrapingLoggedHosts.add(host); dbg("TLS", `using got-scraping for ${host}`); } - } catch { /* noop */ } + } catch { } } return res; } catch (e) { @@ -95,6 +95,7 @@ async function tryGotScrapingFetch(url, options) { return null; } } +*/ // DNS cache — use Map to avoid prototype pollution via malformed hostnames const DNS_CACHE = new Map(); diff --git a/package.json b/package.json index c15d0f89..340622f1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "confbox": "^0.2.4", "express": "^5.2.1", "fs": "^0.0.1-security", - "got-scraping": "^4.2.1", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", diff --git a/src/mitm/antigravityIdeVersion.js b/src/mitm/antigravityIdeVersion.js index cac9bc47..c2bb8cce 100644 --- a/src/mitm/antigravityIdeVersion.js +++ b/src/mitm/antigravityIdeVersion.js @@ -19,7 +19,7 @@ function rewriteAntigravityUserAgent(userAgent, version) { return userAgent.replace(/antigravity\/[^\s]+/, `antigravity/${version}`); } -function applyAntigravityIdeVersionOverride(bodyBuffer, headers, log = () => {}) { +function applyAntigravityIdeVersionOverride(bodyBuffer, headers) { if (!ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED) { return { bodyBuffer, headers, applied: false, version: ANTIGRAVITY_IDE_VERSION }; } @@ -32,22 +32,13 @@ function applyAntigravityIdeVersionOverride(bodyBuffer, headers, log = () => {}) try { const parsed = JSON.parse(bodyBuffer.toString()); if (!shouldRewriteMetadata(parsed?.metadata)) { - if (userAgentChanged) log(`🛰️ [antigravity] user-agent version override → ${ANTIGRAVITY_IDE_VERSION}`); return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; } - - const previousVersion = parsed.metadata.ideVersion; parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION; const nextBodyBuffer = Buffer.from(JSON.stringify(parsed)); - log(`🛰️ [antigravity] IDE version override: ${previousVersion || "unknown"} → ${ANTIGRAVITY_IDE_VERSION}`); return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION }; - } catch (e) { - if (userAgentChanged) { - log(`🛰️ [antigravity] user-agent version override → ${ANTIGRAVITY_IDE_VERSION}`); - return { bodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION }; - } - log(`🛰️ [antigravity] IDE version override skipped: ${e.message}`); - return { bodyBuffer, headers: nextHeaders, applied: false, version: ANTIGRAVITY_IDE_VERSION }; + } catch { + return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; } } diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js index 7c8fe61f..f8f8fe70 100644 --- a/src/mitm/cert/install.js +++ b/src/mitm/cert/install.js @@ -49,12 +49,14 @@ function checkCertInstalledMac(certPath) { return new Promise((resolve) => { try { const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); - // security verify-cert returns 0 only if cert is trusted by system policy - exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (error) => { - if (!error) return resolve(true); - // Fallback: check if fingerprint appears in System keychain with trust - exec(`security dump-trust-settings -d 2>/dev/null | grep -i "${fingerprint}"`, { windowsHide: true }, (err2, stdout2) => { - resolve(!err2 && !!stdout2?.trim()); + // Verify exact cert bytes match — same CN with different fingerprint = stale cert + exec(`security find-certificate -a -c "${ROOT_CA_CN}" -Z /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (error, stdout) => { + if (error || !stdout) return resolve(false); + const match = new RegExp(`SHA-1 hash:\\s*${fingerprint}`, "i").test(stdout); + if (!match) return resolve(false); + // Cert exists with matching fingerprint — confirm trust policy + exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (err2) => { + resolve(!err2); }); }); } catch { diff --git a/src/mitm/manager.js b/src/mitm/manager.js index be13603e..bd6f99fe 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -635,6 +635,25 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { mitmLastStartTime = Date.now(); } + // Set NODE_EXTRA_CA_CERTS so Node-based GUI apps (Electron/AG language_server) trust MITM cert + if (IS_MAC) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`launchctl setenv NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS set to ${rootCAPath}`); + }); + } + } else if (IS_WIN) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`setx NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[setx] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[setx] NODE_EXTRA_CA_CERTS set for current user`); + }); + } + } + let startError = null; if (serverProcess) { serverProcess.stdout.on("data", (data) => { @@ -746,6 +765,19 @@ async function stopServer(sudoPassword) { await removeAllDNSEntries(sudoPassword); } + // Unset NODE_EXTRA_CA_CERTS so apps don't keep trusting stale MITM cert + if (IS_MAC) { + exec(`launchctl unsetenv NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS unset`); + }); + } else if (IS_WIN) { + exec(`reg delete HKCU\\Environment /F /V NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[reg] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[reg] NODE_EXTRA_CA_CERTS unset`); + }); + } + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } await saveMitmSettings(false, null); mitmIsRestarting = false; diff --git a/src/mitm/server.js b/src/mitm/server.js index 09469ad0..cf5b23d9 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -1,6 +1,4 @@ const https = require("https"); -const http2 = require("http2"); -const tls = require("tls"); const fs = require("fs"); const path = require("path"); const dns = require("dns"); @@ -135,11 +133,12 @@ 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); const versionOverride = tool === "antigravity" - ? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers, log) + ? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers) : { bodyBuffer, headers: req.headers }; const bodyForForwarding = versionOverride.bodyBuffer; const headersForForwarding = { ...versionOverride.headers, host: targetHost }; @@ -147,121 +146,12 @@ 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, + headers: headersForForwarding, servername: targetHost, rejectUnauthorized: false }, (forwardRes) => {