From 8221f7c027bb569c752168afb1e0ec9685ff2caf Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 23 Feb 2026 21:56:40 +0700 Subject: [PATCH] Fix MITM --- .../translator/request/openai-to-cursor.js | 59 ++++++- package.json | 2 +- .../dashboard/providers/[id]/page.js | 13 -- src/app/callback/page.js | 79 +-------- src/mitm/manager.js | 162 +++++++++++------- src/mitm/server.js | 7 + src/shared/components/OAuthModal.js | 56 +----- 7 files changed, 165 insertions(+), 213 deletions(-) diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js index 64c4a36..d2b78d8 100644 --- a/open-sse/translator/request/openai-to-cursor.js +++ b/open-sse/translator/request/openai-to-cursor.js @@ -1,6 +1,7 @@ /** * OpenAI to Cursor Request Translator * - assistant tool_calls → kept as-is (Cursor generates tool calls) + * - Claude tool_use blocks → converted to OpenAI tool_calls format * - tool results → converted to user message string */ import { register } from "../index.js"; @@ -14,6 +15,16 @@ function extractContent(content) { return ""; } +// Build a map of tool_use_id → tool_name from the previous assistant message +function getToolNameMap(prevMsg) { + const map = {}; + if (!prevMsg?.tool_calls) return map; + for (const tc of prevMsg.tool_calls) { + if (tc.id && tc.function?.name) map[tc.id] = tc.function.name; + } + return map; +} + function convertMessages(messages) { const result = []; @@ -26,7 +37,25 @@ function convertMessages(messages) { } if (msg.role === "user") { - result.push({ role: "user", content: extractContent(msg.content) || "" }); + if (Array.isArray(msg.content)) { + const parts = []; + const prevMsg = result[result.length - 1]; + const nameMap = getToolNameMap(prevMsg); + for (const block of msg.content) { + if (block.type === "text") { + parts.push(block.text); + } else if (block.type === "tool_result") { + // Claude format: user message with tool_result blocks + const toolResultText = extractContent(block.content) || ""; + const toolCallId = block.tool_use_id || ""; + const toolName = nameMap[toolCallId] || ""; + parts.push(`\n${toolName}\n${toolCallId}\n${toolResultText}\n`); + } + } + result.push({ role: "user", content: parts.join("\n") || "" }); + } else { + result.push({ role: "user", content: extractContent(msg.content) || "" }); + } continue; } @@ -34,10 +63,10 @@ function convertMessages(messages) { // Strip system-reminder tags injected by Claude Code const raw = extractContent(msg.content) || ""; const toolContent = raw.replace(/[\s\S]*?<\/system-reminder>/g, "").trim(); - // Find matching tool name from previous assistant message const prevMsg = result[result.length - 1]; - const toolName = prevMsg?.tool_calls?.[0]?.function?.name || ""; + const nameMap = getToolNameMap(prevMsg); const toolCallId = msg.tool_call_id || ""; + const toolName = nameMap[toolCallId] || ""; result.push({ role: "user", content: `\n${toolName}\n${toolCallId}\n${toolContent}\n` @@ -46,10 +75,28 @@ function convertMessages(messages) { } if (msg.role === "assistant") { - const content = extractContent(msg.content) || ""; + let content = extractContent(msg.content) || ""; + let tool_calls = null; + if (msg.tool_calls && msg.tool_calls.length > 0) { - // Strip `index` field — not needed in history, may confuse Cursor - const tool_calls = msg.tool_calls.map(({ index, ...tc }) => tc); + // OpenAI format: strip `index` field + tool_calls = msg.tool_calls.map(({ index, ...tc }) => tc); + } else if (Array.isArray(msg.content)) { + // Claude format: extract tool_use blocks from content array + const extracted = msg.content + .filter(b => b.type === "tool_use") + .map(b => ({ + id: b.id, + type: "function", + function: { + name: b.name, + arguments: JSON.stringify(b.input || {}) + } + })); + if (extracted.length > 0) tool_calls = extracted; + } + + if (tool_calls) { result.push({ role: "assistant", content, tool_calls }); } else if (content) { result.push({ role: "assistant", content }); diff --git a/package.json b/package.json index b79cfb9..5f7457d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.2.92", + "version": "0.2.95", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 1c61f52..b52f4dd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -18,19 +18,6 @@ export default function ProviderDetailPage() { const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); const [showOAuthModal, setShowOAuthModal] = useState(false); - - // Auto-reopen OAuthModal if pending auth exists (survives HMR/reload) - useEffect(() => { - try { - const raw = localStorage.getItem("oauth_pending_auth"); - if (raw) { - const data = JSON.parse(raw); - if (data.provider === providerId && Date.now() - data.timestamp < 300000) { - setShowOAuthModal(true); - } - } - } catch { /* ignore */ } - }, [providerId]); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); diff --git a/src/app/callback/page.js b/src/app/callback/page.js index 882ef3c..4064b03 100644 --- a/src/app/callback/page.js +++ b/src/app/callback/page.js @@ -3,43 +3,6 @@ import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -const OAUTH_SESSION_KEY = "oauth_pending_auth"; - -/** - * Direct exchange: callback page calls exchange API itself - * when relay to opener fails (e.g. HMR reload destroyed listeners) - */ -async function directExchange(code, state) { - try { - const raw = localStorage.getItem(OAUTH_SESSION_KEY); - if (!raw) return false; - - const session = JSON.parse(raw); - // Expired (5 min) - if (Date.now() - session.timestamp > 300000) { - localStorage.removeItem(OAUTH_SESSION_KEY); - return false; - } - - const res = await fetch(`/api/oauth/${session.provider}/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - redirectUri: session.redirectUri, - codeVerifier: session.codeVerifier, - state, - }), - }); - - const data = await res.json(); - localStorage.removeItem(OAUTH_SESSION_KEY); - return res.ok && data.success; - } catch { - return false; - } -} - /** * OAuth Callback Page Content */ @@ -102,43 +65,11 @@ function CallbackContent() { return; } - if (error) { - setTimeout(() => { - setStatus("success"); - setTimeout(() => { - window.close(); - setTimeout(() => setStatus("done"), 500); - }, 1500); - }, 0); - return; - } - - // Try direct exchange FIRST (before relay may be lost to HMR) - // Then relay as backup for normal flow - const handleExchange = async () => { - const pending = localStorage.getItem(OAUTH_SESSION_KEY); - if (pending) { - // Direct exchange - works even if opener was destroyed by HMR - const ok = await directExchange(code, state); - if (ok) { - setStatus("success"); - setTimeout(() => { - window.close(); - setTimeout(() => setStatus("done"), 500); - }, 1500); - return; - } - } - - // Fallback: relay succeeded and OAuthModal handled it - setStatus("success"); - setTimeout(() => { - window.close(); - setTimeout(() => setStatus("done"), 500); - }, 1500); - }; - - handleExchange(); + setStatus("success"); + setTimeout(() => { + window.close(); + setTimeout(() => setStatus("done"), 500); + }, 1500); }, [searchParams]); return ( diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 98cab22..c3eb05e 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -3,6 +3,7 @@ const path = require("path"); const fs = require("fs"); const os = require("os"); const net = require("net"); +const https = require("https"); const crypto = require("crypto"); const { addDNSEntry, removeDNSEntry, checkDNSEntry } = require("./dns/dnsConfig"); @@ -49,12 +50,14 @@ function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } // Check if a PID is alive +// EACCES = process exists but no permission (e.g. root process) → still alive +// ESRCH = process does not exist → dead function isProcessAlive(pid) { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (err) { + return err.code === "EACCES"; } } @@ -165,53 +168,36 @@ function checkPort443Free() { * Get PID and process name currently holding port 443 * Returns { pid, name } or null if port is free / cannot determine */ -function getPort443Owner() { +function getPort443Owner(sudoPassword) { return new Promise((resolve) => { - const cmd = IS_WIN - ? `netstat -ano | findstr ":443 "` - // Only match TCP processes actually LISTEN-ing on port 443 (not outbound UDP/QUIC) - : `lsof -i TCP:${MITM_PORT} -n -P -sTCP:LISTEN`; - - exec(cmd, (err, stdout) => { - if (err || !stdout.trim()) return resolve(null); - - let pid = null; - - if (IS_WIN) { - // netstat line: " TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 1234" + if (IS_WIN) { + exec(`netstat -ano | findstr ":443 "`, (err, stdout) => { + if (err || !stdout.trim()) return resolve(null); for (const line of stdout.split("\n")) { const match = line.match(/LISTENING\s+(\d+)/i); - if (match) { pid = parseInt(match[1], 10); break; } - } - } else { - // lsof line: "node 1234 user ..." - for (const line of stdout.split("\n").slice(1)) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 2) { pid = parseInt(parts[1], 10); break; } - } - } - - if (!pid || isNaN(pid)) return resolve(null); - - // Get process name by PID - const nameCmd = IS_WIN - ? `tasklist /FI "PID eq ${pid}" /FO CSV /NH` - : `ps -p ${pid} -o comm=`; - - exec(nameCmd, (e2, out2) => { - let name = "unknown"; - if (!e2 && out2.trim()) { - if (IS_WIN) { - // CSV: "node.exe","1234",... - const m = out2.match(/"([^"]+)"/); - if (m) name = m[1]; - } else { - name = out2.trim(); + if (match) { + const pid = parseInt(match[1], 10); + exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, (e2, out2) => { + const m = out2?.match(/"([^"]+)"/); + resolve({ pid, name: m ? m[1] : "unknown" }); + }); + return; } } - resolve({ pid, name }); + resolve(null); }); - }); + } else { + // Use ps to find node process running server.js (no sudo needed) + exec(`ps aux | grep "[s]erver.js"`, (err, stdout) => { + if (!stdout?.trim()) return resolve(null); + for (const line of stdout.split("\n")) { + const parts = line.trim().split(/\s+/); + const pid = parseInt(parts[1], 10); + if (!isNaN(pid)) return resolve({ pid, name: "node" }); + } + resolve(null); + }); + } }); } @@ -254,6 +240,37 @@ async function killLeftoverMitm(sudoPassword) { } } +/** + * Poll MITM health endpoint until server is up or timeout. + * Returns { ok, pid } on success, null on timeout. + */ +function pollMitmHealth(timeoutMs) { + return new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + const check = () => { + const req = https.request( + { hostname: "127.0.0.1", port: 443, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, + (res) => { + let body = ""; + res.on("data", (d) => { body += d; }); + res.on("end", () => { + try { + const json = JSON.parse(body); + resolve(json.ok === true ? { ok: true, pid: json.pid || null } : null); + } catch { resolve(null); } + }); + } + ); + req.on("error", () => { + if (Date.now() < deadline) setTimeout(check, 500); + else resolve(null); + }); + req.end(); + }; + check(); + }); +} + /** * Get MITM status */ @@ -319,19 +336,33 @@ async function startMitm(apiKey, sudoPassword) { await killLeftoverMitm(sudoPassword); // Check port 443 availability BEFORE modifying system + // "no-permission" = EACCES: port may be held by a root process, check via lsof/netstat const portStatus = await checkPort443Free(); - if (portStatus === "in-use") { - const owner = await getPort443Owner(); - let ownerDesc = "another process"; - if (owner) { + if (portStatus === "in-use" || portStatus === "no-permission") { + const owner = await getPort443Owner(sudoPassword); + if (owner && owner.name === "node") { + // Orphan MITM node process — kill it and continue + console.log(`[MITM] Killing orphan node process on port 443 (PID ${owner.pid})...`); + try { + if (IS_WIN) { + await new Promise((resolve) => exec(`taskkill /F /PID ${owner.pid}`, resolve)); + } else { + const { execWithPassword } = require("./dns/dnsConfig"); + await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword); + } + await new Promise(r => setTimeout(r, 800)); + } catch { + // best effort — continue anyway + } + } else if (owner) { const shortName = owner.name.includes("/") ? owner.name.split("/").filter(Boolean).pop() : owner.name; - ownerDesc = `"${shortName}" (PID ${owner.pid})`; + throw new Error( + `Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first, then retry.` + ); } - throw new Error( - `Port 443 is already in use by ${ownerDesc}. Stop that process first, then retry.` - ); + // owner === null + no-permission → likely just needs sudo, proceed } // 1. Generate SSL certificate if not exists @@ -358,12 +389,13 @@ async function startMitm(apiKey, sudoPassword) { console.log("Starting MITM server..."); if (IS_WIN) { - const nodePath = process.execPath; - const envArgs = `$env:ROUTER_API_KEY='${apiKey}'; $env:NODE_ENV='production'; & '${nodePath}' '${SERVER_PATH}'`; + // Launch elevated node via PowerShell RunAs (triggers UAC prompt) + const nodePath = process.execPath.replace(/'/g, "''"); + const serverPath = SERVER_PATH.replace(/'/g, "''"); serverProcess = spawn("powershell", [ - "-Command", - `Start-Process powershell -ArgumentList '-NoProfile','-Command','${envArgs.replace(/'/g, "''")}' -Verb RunAs -PassThru` - ], { detached: false, stdio: ["ignore", "pipe", "pipe"] }); + "-NoProfile", "-Command", + `$env:ROUTER_API_KEY='${apiKey}'; $env:NODE_ENV='production'; Start-Process '${nodePath}' -ArgumentList '''${serverPath}''' -Verb RunAs -WindowStyle Hidden` + ], { stdio: "ignore", env: process.env }); } else { // sudo -S: read password from stdin, -E: preserve env vars // Pass ROUTER_API_KEY inline via env=... wrapper to avoid sudo stripping env @@ -399,20 +431,22 @@ async function startMitm(apiKey, sudoPassword) { try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } }); - // Wait up to 8s — sudo + Node startup takes longer than plain spawn - const started = await new Promise((resolve) => { - let resolved = false; - const done = (val) => { if (!resolved) { resolved = true; resolve(val); } }; - const timeout = setTimeout(() => done(true), 8000); - serverProcess.once("exit", () => { clearTimeout(timeout); done(false); }); - }); + // Wait for server to be ready by polling health endpoint + const health = await pollMitmHealth(IS_WIN ? 12000 : 8000); - if (!started) { + if (!health) { + if (IS_WIN) serverProcess = null; try { await removeDNSEntry(sudoPassword); } catch { /* best effort */ } const reason = startError || "Check sudo password or port 443 access."; throw new Error(`MITM server failed to start. ${reason}`); } + // On Windows, use real PID from health check (launcher exits immediately after UAC) + if (IS_WIN && health.pid) { + serverPid = health.pid; + fs.writeFileSync(PID_FILE, String(serverPid)); + } + await saveMitmSettings(true, sudoPassword); if (sudoPassword) setCachedPassword(sudoPassword); diff --git a/src/mitm/server.js b/src/mitm/server.js index 62560fa..70f5ab9 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -173,6 +173,13 @@ async function intercept(req, res, bodyBuffer, mappedModel) { } const server = https.createServer(sslOptions, async (req, res) => { + // Health check endpoint for startup verification + if (req.url === "/_mitm_health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, pid: process.pid })); + return; + } + const bodyBuffer = await collectBodyRaw(req); // Save request log if enabled diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index aec7153..c78f9f3 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -5,34 +5,6 @@ import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -const OAUTH_SESSION_KEY = "oauth_pending_auth"; - -function saveAuthSession(provider, data) { - try { - localStorage.setItem(OAUTH_SESSION_KEY, JSON.stringify({ provider, ...data, timestamp: Date.now() })); - } catch { /* storage unavailable */ } -} - -function loadAuthSession(provider) { - try { - const raw = localStorage.getItem(OAUTH_SESSION_KEY); - if (!raw) return null; - const data = JSON.parse(raw); - // Only restore if same provider and within 5 minutes - if (data.provider !== provider || Date.now() - data.timestamp > 300000) { - localStorage.removeItem(OAUTH_SESSION_KEY); - return null; - } - return data; - } catch { - return null; - } -} - -function clearAuthSession() { - try { localStorage.removeItem(OAUTH_SESSION_KEY); } catch { /* ignore */ } -} - /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -84,11 +56,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const data = await res.json(); if (!res.ok) throw new Error(data.error); - clearAuthSession(); setStep("success"); onSuccess?.(); } catch (err) { - clearAuthSession(); setError(err.message); setStep("error"); } @@ -182,7 +152,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!res.ok) throw new Error(data.error); setAuthData({ ...data, redirectUri }); - saveAuthSession(provider, { codeVerifier: data.codeVerifier, redirectUri, state: data.state }); // For Codex or non-localhost: use manual input mode if (provider === "codex" || !isLocalhost) { @@ -207,19 +176,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Reset state and start OAuth when modal opens useEffect(() => { if (isOpen && provider) { - // Try restore pending auth from localStorage (survives HMR/reload) - const saved = loadAuthSession(provider); - if (saved) { - setAuthData({ codeVerifier: saved.codeVerifier, redirectUri: saved.redirectUri, state: saved.state }); - setStep("waiting"); - setCallbackUrl(""); - setError(null); - setIsDeviceCode(false); - setDeviceData(null); - setPolling(false); - return; // Don't restart OAuth — just re-listen for callback - } - setAuthData(null); setCallbackUrl(""); setError(null); @@ -249,14 +205,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } if (code) { - // Skip if callback page already handled exchange (localStorage cleared) - const stillPending = localStorage.getItem(OAUTH_SESSION_KEY); - if (!stillPending) { - callbackProcessedRef.current = true; - setStep("success"); - onSuccess?.(); - return; - } callbackProcessedRef.current = true; await exchangeTokens(code, state); } @@ -303,11 +251,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const stored = localStorage.getItem("oauth_callback"); if (stored) { const data = JSON.parse(stored); - // Only use if recent (within 30 seconds) if (data.timestamp && Date.now() - data.timestamp < 30000) { handleCallback(data); - localStorage.removeItem("oauth_callback"); } + localStorage.removeItem("oauth_callback"); } } catch { // localStorage may be unavailable or data may be malformed - ignore silently @@ -346,7 +293,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Clear session on modal close const handleClose = useCallback(() => { - clearAuthSession(); onClose(); }, [onClose]);