# 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 (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)
This commit is contained in:
decolua
2026-05-26 12:46:30 +07:00
parent f3176b4b23
commit 0065bbbdfd
9 changed files with 74 additions and 143 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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;

View File

@@ -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();

View File

@@ -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",

View File

@@ -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 };
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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) => {