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