This commit is contained in:
decolua
2026-02-23 21:56:40 +07:00
parent d21f7aaadc
commit 8221f7c027
7 changed files with 165 additions and 213 deletions

View File

@@ -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(`<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolResultText}</result>\n</tool_result>`);
}
}
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(/<system-reminder>[\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: `<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolContent}</result>\n</tool_result>`
@@ -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 });

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.2.92",
"version": "0.2.95",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

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

View File

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

View File

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

View File

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

View File

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