diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index e16d3b7..ce6b80c 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -12,8 +12,8 @@ export class AntigravityExecutor extends BaseExecutor { buildUrl(model, stream, urlIndex = 0) { const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; - const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent"; - return `${baseUrl}${path}`; + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `${baseUrl}/v1internal:${action}`; } buildHeaders(credentials, stream = true) { @@ -21,6 +21,7 @@ export class AntigravityExecutor extends BaseExecutor { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.accessToken}`, "User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64", + "X-9Router-Source": "9router", ...(stream && { "Accept": "text/event-stream" }) }; } @@ -28,8 +29,24 @@ export class AntigravityExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); + // Fix contents for Claude models via Antigravity + const contents = body.request?.contents?.map(c => { + let role = c.role; + // functionResponse must be role "user" for Claude models + if (c.parts?.some(p => p.functionResponse)) { + role = "user"; + } + // Strip thought parts (no valid signature → provider rejects) + const parts = c.parts?.filter(p => !p.thought && !p.thoughtSignature); + if (role !== c.role || parts?.length !== c.parts?.length) { + return { ...c, role, parts }; + } + return c; + }); + const transformedRequest = { ...body.request, + ...(contents && { contents }), sessionId: body.request?.sessionId || this.generateSessionId(), safetySettings: undefined, toolConfig: body.request?.tools?.length > 0 diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index f6e527b..4c50efe 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -41,6 +41,11 @@ export function detectFormat(body) { return "openai-responses"; } + // Antigravity format: Gemini wrapped in body.request + if (body.request?.contents && body.userAgent === "antigravity") { + return "antigravity"; + } + // Gemini format: has contents array if (body.contents && Array.isArray(body.contents)) { return "gemini"; diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 326746c..2bf8969 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -32,6 +32,7 @@ function ensureInitialized() { require("./request/openai-to-claude.js"); require("./request/gemini-to-openai.js"); require("./request/openai-to-gemini.js"); + require("./request/antigravity-to-openai.js"); require("./request/openai-responses.js"); require("./request/openai-to-kiro.js"); require("./request/openai-to-cursor.js"); @@ -40,6 +41,7 @@ function ensureInitialized() { require("./response/claude-to-openai.js"); require("./response/openai-to-claude.js"); require("./response/gemini-to-openai.js"); + require("./response/openai-to-antigravity.js"); require("./response/openai-responses.js"); require("./response/kiro-to-openai.js"); require("./response/cursor-to-openai.js"); diff --git a/open-sse/translator/request/antigravity-to-openai.js b/open-sse/translator/request/antigravity-to-openai.js new file mode 100644 index 0000000..8d729a5 --- /dev/null +++ b/open-sse/translator/request/antigravity-to-openai.js @@ -0,0 +1,223 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; + +// Convert Antigravity request to OpenAI format +// Antigravity body: { project, model, userAgent, requestType, requestId, request: { contents, systemInstruction, tools, toolConfig, generationConfig, sessionId } } +export function antigravityToOpenAIRequest(model, body, stream) { + const req = body.request || body; + const result = { + model: model, + messages: [], + stream: stream + }; + + // Generation config + if (req.generationConfig) { + const config = req.generationConfig; + if (config.maxOutputTokens) { + const tempBody = { max_tokens: config.maxOutputTokens, tools: req.tools }; + result.max_tokens = adjustMaxTokens(tempBody); + } + if (config.temperature !== undefined) { + result.temperature = config.temperature; + } + if (config.topP !== undefined) { + result.top_p = config.topP; + } + if (config.topK !== undefined) { + result.top_k = config.topK; + } + + // Thinking config → reasoning_effort + if (config.thinkingConfig) { + const budget = config.thinkingConfig.thinkingBudget || 0; + if (budget > 0) { + if (budget <= 2048) { + result.reasoning_effort = "low"; + } else if (budget <= 16384) { + result.reasoning_effort = "medium"; + } else { + result.reasoning_effort = "high"; + } + } + } + } + + // System instruction + if (req.systemInstruction) { + const systemText = extractText(req.systemInstruction); + if (systemText) { + result.messages.push({ role: "system", content: systemText }); + } + } + + // Convert contents to messages + if (req.contents && Array.isArray(req.contents)) { + for (const content of req.contents) { + const converted = convertContent(content); + if (converted) { + if (Array.isArray(converted)) { + result.messages.push(...converted); + } else { + result.messages.push(converted); + } + } + } + } + + // Tools + if (req.tools && Array.isArray(req.tools)) { + result.tools = []; + for (const tool of req.tools) { + if (tool.functionDeclarations) { + for (const func of tool.functionDeclarations) { + result.tools.push({ + type: "function", + function: { + name: func.name, + description: func.description || "", + parameters: normalizeSchemaTypes(func.parameters) || { type: "object", properties: {} } + } + }); + } + } + } + } + + return result; +} + +// Recursively convert Antigravity schema types (OBJECT, STRING, etc.) to lowercase +function normalizeSchemaTypes(schema) { + if (!schema || typeof schema !== "object") return schema; + + const result = Array.isArray(schema) ? [...schema] : { ...schema }; + + if (typeof result.type === "string") { + result.type = result.type.toLowerCase(); + } + + if (result.properties) { + const normalized = {}; + for (const [key, val] of Object.entries(result.properties)) { + normalized[key] = normalizeSchemaTypes(val); + } + result.properties = normalized; + } + + if (result.items) { + result.items = normalizeSchemaTypes(result.items); + } + + return result; +} + +// Convert Antigravity content to OpenAI message +// Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData +function convertContent(content) { + const role = content.role === "model" ? "assistant" : content.role === "user" ? "user" : content.role; + + if (!content.parts || !Array.isArray(content.parts)) { + return null; + } + + const textParts = []; + const toolCalls = []; + const toolResults = []; + let reasoningContent = ""; + + for (const part of content.parts) { + // Thinking content (thought: true) + if (part.thought === true && part.text) { + reasoningContent += part.text; + continue; + } + + // Text with thoughtSignature = regular text after thinking + if (part.thoughtSignature && part.text !== undefined) { + textParts.push({ type: "text", text: part.text }); + continue; + } + + // Regular text + if (part.text !== undefined) { + textParts.push({ type: "text", text: part.text }); + } + + // Inline data (images) + if (part.inlineData) { + textParts.push({ + type: "image_url", + image_url: { + url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` + } + }); + } + + // Function call + if (part.functionCall) { + toolCalls.push({ + id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + type: "function", + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args || {}) + } + }); + } + + // Function response → collect all, each becomes a separate tool message + if (part.functionResponse) { + toolResults.push({ + role: "tool", + tool_call_id: part.functionResponse.id || part.functionResponse.name, + content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) + }); + } + } + + // Content with only functionResponses → return array of tool messages + if (toolResults.length > 0) { + return toolResults; + } + + // Assistant with tool calls + if (toolCalls.length > 0) { + const msg = { role: "assistant" }; + if (textParts.length > 0) { + msg.content = textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + } + if (reasoningContent) { + msg.reasoning_content = reasoningContent; + } + msg.tool_calls = toolCalls; + return msg; + } + + // Regular message + if (textParts.length > 0 || reasoningContent) { + const msg = { role }; + if (textParts.length > 0) { + msg.content = textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + } + if (reasoningContent) { + msg.reasoning_content = reasoningContent; + } + return msg; + } + + return null; +} + +// Extract text from systemInstruction +function extractText(instruction) { + if (typeof instruction === "string") return instruction; + if (instruction.parts && Array.isArray(instruction.parts)) { + return instruction.parts.map(p => p.text || "").join(""); + } + return ""; +} + +// Register +register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, antigravityToOpenAIRequest, null); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 5cb413d..b48f864 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -86,6 +86,18 @@ function openaiToGeminiBase(model, body, stream) { } else if (role === "assistant") { const parts = []; + // Thinking/reasoning → thought part with signature + if (msg.reasoning_content) { + parts.push({ + thought: true, + text: msg.reasoning_content + }); + parts.push({ + thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, + text: "" + }); + } + if (content) { const text = typeof content === "string" ? content : extractTextContent(content); if (text) { diff --git a/open-sse/translator/response/openai-to-antigravity.js b/open-sse/translator/response/openai-to-antigravity.js new file mode 100644 index 0000000..b1c3ebf --- /dev/null +++ b/open-sse/translator/response/openai-to-antigravity.js @@ -0,0 +1,120 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +// Convert OpenAI SSE chunk to Antigravity SSE format +// Real Antigravity format: +// data: {"response":{"candidates":[{"content":{"role":"model","parts":[...]}, "finishReason":"STOP"}], "usageMetadata":{...}, "modelVersion":"...", "responseId":"..."}} +// Tool calls: OpenAI sends incremental args across chunks → accumulate and emit ONCE at finish +export function openaiToAntigravityResponse(chunk, state) { + if (!chunk) return null; + + const choice = chunk.choices?.[0]; + if (!choice) { + if (chunk.usage) { + state._usage = chunk.usage; + } + return null; + } + + const delta = choice.delta || {}; + const finishReason = choice.finish_reason; + + // Init state + if (!state._toolCallAccum) state._toolCallAccum = {}; + if (!state._responseId) state._responseId = chunk.id || `resp_${Date.now()}`; + if (!state._modelVersion) state._modelVersion = chunk.model || ""; + + const parts = []; + + // Thinking/reasoning → thought part + if (delta.reasoning_content) { + parts.push({ thought: true, text: delta.reasoning_content }); + } + + // Text content + if (delta.content) { + parts.push({ text: delta.content }); + } + + // Accumulate tool calls silently (no emit until finish) + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!state._toolCallAccum[idx]) { + state._toolCallAccum[idx] = { id: "", name: "", arguments: "" }; + } + const accum = state._toolCallAccum[idx]; + if (tc.id) accum.id = tc.id; + if (tc.function?.name) accum.name += tc.function.name; + if (tc.function?.arguments) accum.arguments += tc.function.arguments; + } + // Skip emit — wait for finish_reason + if (parts.length === 0 && !finishReason) return null; + } + + // On finish, emit accumulated tool calls as complete functionCall parts + if (finishReason) { + const indices = Object.keys(state._toolCallAccum); + for (const idx of indices) { + const accum = state._toolCallAccum[idx]; + let args = {}; + try { args = JSON.parse(accum.arguments); } catch { /* empty */ } + parts.push({ + functionCall: { + name: accum.name, + args + } + }); + } + } + + // Skip empty non-finish chunks + if (parts.length === 0 && !finishReason) return null; + + // Ensure at least empty text part on finish with no content + if (parts.length === 0 && finishReason) { + parts.push({ text: "" }); + } + + // Build candidate + const candidate = { content: { role: "model", parts } }; + + // Finish reason mapping + if (finishReason) { + const reasonMap = { + "stop": "STOP", + "length": "MAX_TOKENS", + "tool_calls": "STOP", + "content_filter": "SAFETY" + }; + candidate.finishReason = reasonMap[finishReason] || "STOP"; + } + + // Build response + const response = { + candidates: [candidate], + modelVersion: state._modelVersion, + responseId: state._responseId + }; + + // Usage metadata + const usage = chunk.usage || state._usage; + if (usage) { + response.usageMetadata = { + promptTokenCount: usage.prompt_tokens || 0, + candidatesTokenCount: usage.completion_tokens || 0, + totalTokenCount: usage.total_tokens || 0 + }; + if (usage.completion_tokens_details?.reasoning_tokens) { + response.usageMetadata.thoughtsTokenCount = usage.completion_tokens_details.reasoning_tokens; + } + if (usage.prompt_tokens_details?.cached_tokens) { + response.usageMetadata.cachedContentTokenCount = usage.prompt_tokens_details.cached_tokens; + } + } + + return { response }; +} + +// Register +register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, null, openaiToAntigravityResponse); diff --git a/package.json b/package.json index 3cd0ec1..73b5724 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack", + "dev": "next dev --webpack --port 20128", "build": "next build --webpack", "start": "next start" }, @@ -25,6 +25,7 @@ "ora": "^9.1.0", "react": "19.2.4", "react-dom": "19.2.4", + "selfsigned": "^5.5.0", "socks-proxy-agent": "^8.0.5", "undici": "^7.19.2", "uuid": "^13.0.0", diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 1bc23d0..2bf0a1c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -160,6 +160,8 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "openclaw": return ; + case "antigravity": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js new file mode 100644 index 0000000..5f99bc6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -0,0 +1,414 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components"; +import Image from "next/image"; + +export default function AntigravityToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + hasActiveProviders, + cloudEnabled, +}) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [sudoPassword, setSudoPassword] = useState(""); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [message, setMessage] = useState(null); + const [modelMappings, setModelMappings] = useState({}); + const [modalOpen, setModalOpen] = useState(false); + const [currentEditingAlias, setCurrentEditingAlias] = useState(null); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !status) { + fetchStatus(); + loadSavedMappings(); + } + }, [isExpanded, status]); + + const loadSavedMappings = async () => { + try { + const res = await fetch("/api/cli-tools/antigravity-mitm/alias?tool=antigravity"); + if (res.ok) { + const data = await res.json(); + const aliases = data.aliases || {}; + + if (Object.keys(aliases).length > 0) { + setModelMappings(aliases); + } + } + } catch (error) { + console.log("Error loading saved mappings:", error); + } + }; + + const fetchStatus = async () => { + try { + const res = await fetch("/api/cli-tools/antigravity-mitm"); + if (res.ok) { + const data = await res.json(); + setStatus(data); + } + } catch (error) { + console.log("Error fetching status:", error); + setStatus({ running: false }); + } + }; + + // Windows uses UAC dialog, no sudo needed + const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); + + const handleStart = () => { + if (isWindows || status?.hasCachedPassword) { + doStart(""); + } else { + setShowPasswordModal(true); + setMessage(null); + } + }; + + const handleStop = () => { + if (isWindows || status?.hasCachedPassword) { + doStop(""); + } else { + setShowPasswordModal(true); + setMessage(null); + } + }; + + const doStart = async (password) => { + setLoading(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }), + }); + + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "MITM started" }); + setShowPasswordModal(false); + setSudoPassword(""); + fetchStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to start" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const doStop = async (password) => { + setLoading(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sudoPassword: password }), + }); + + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "MITM stopped" }); + setShowPasswordModal(false); + setSudoPassword(""); + fetchStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to stop" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const handleConfirmPassword = () => { + if (!sudoPassword.trim()) { + setMessage({ type: "error", text: "Sudo password is required" }); + return; + } + if (status?.running) { + doStop(sudoPassword); + } else { + doStart(sudoPassword); + } + }; + + const openModelSelector = (alias) => { + setCurrentEditingAlias(alias); + setModalOpen(true); + }; + + const handleModelSelect = (model) => { + if (currentEditingAlias) { + setModelMappings(prev => ({ + ...prev, + [currentEditingAlias]: model.value, + })); + } + }; + + const handleModelMappingChange = (alias, value) => { + setModelMappings(prev => ({ + ...prev, + [alias]: value, + })); + }; + + const handleSaveMappings = async () => { + setLoading(true); + setMessage(null); + + try { + const res = await fetch("/api/cli-tools/antigravity-mitm/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "antigravity", mappings: modelMappings }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to save mappings"); + } + + setMessage({ type: "success", text: "Mappings saved!" }); + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const isRunning = status?.running; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ {isRunning ? ( + Active + ) : ( + Inactive + )} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {/* Start/Stop Button - always on top */} +
+ {isRunning ? ( + + ) : ( + + )} +
+ + {message?.type === "error" && ( +
+ error + {message.text} +
+ )} + + {/* When running: API Key + Model Mappings */} + {isRunning && ( + <> +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {tool.defaultModels.map((model) => ( +
+ {model.name} + arrow_forward + handleModelMappingChange(model.alias, e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelMappings[model.alias] && ( + + )} +
+ ))} + +
+ +
+ + )} + + {/* When stopped: how it works */} + {!isRunning && ( +
+

+ How it works: Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router. +

+
+ 1. Generates SSL cert & adds to system keychain + 2. Redirects daily-cloudcode-pa.googleapis.com → localhost + 3. Maps Antigravity models to any provider via 9Router +
+
+ )} +
+ )} + + {/* Password Modal */} + { + setShowPasswordModal(false); + setSudoPassword(""); + setMessage(null); + }} + title="Sudo Password Required" + size="sm" + > +
+
+ warning +

Required for SSL certificate and DNS configuration

+
+ + setSudoPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !loading) handleConfirmPassword(); + }} + /> + + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + +
+
+
+ + {/* Model Select Modal */} + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + title={`Select model for ${currentEditingAlias}`} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 63d3492..5aed48d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -3,4 +3,5 @@ export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; +export { default as AntigravityToolCard } from "./AntigravityToolCard"; diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.js b/src/app/api/cli-tools/antigravity-mitm/alias/route.js new file mode 100644 index 0000000..568701d --- /dev/null +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.js @@ -0,0 +1,41 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { getMitmAlias, setMitmAliasAll } from "@/models"; + +// GET - Get MITM aliases for a tool +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const toolName = searchParams.get("tool"); + const aliases = await getMitmAlias(toolName || undefined); + return NextResponse.json({ aliases }); + } catch (error) { + console.log("Error fetching MITM aliases:", error.message); + return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); + } +} + +// PUT - Save MITM aliases for a specific tool +export async function PUT(request) { + try { + const { tool, mappings } = await request.json(); + + if (!tool || !mappings || typeof mappings !== "object") { + return NextResponse.json({ error: "tool and mappings required" }, { status: 400 }); + } + + const filtered = {}; + for (const [alias, model] of Object.entries(mappings)) { + if (model && model.trim()) { + filtered[alias] = model.trim(); + } + } + + await setMitmAliasAll(tool, filtered); + return NextResponse.json({ success: true, aliases: filtered }); + } catch (error) { + console.log("Error saving MITM aliases:", error.message); + return NextResponse.json({ error: "Failed to save aliases" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js new file mode 100644 index 0000000..8dfc553 --- /dev/null +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -0,0 +1,70 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { getMitmStatus, startMitm, stopMitm, getCachedPassword, setCachedPassword } from "@/mitm/manager"; + +// GET - Check MITM status +export async function GET() { + try { + const status = await getMitmStatus(); + return NextResponse.json({ + running: status.running, + pid: status.pid || null, + dnsConfigured: status.dnsConfigured || false, + certExists: status.certExists || false, + hasCachedPassword: !!getCachedPassword(), + }); + } catch (error) { + console.log("Error getting MITM status:", error.message); + return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 }); + } +} + +// POST - Start MITM proxy +export async function POST(request) { + try { + const { apiKey, sudoPassword } = await request.json(); + const isWin = process.platform === "win32"; + const pwd = sudoPassword || getCachedPassword() || ""; + + if (!apiKey || (!isWin && !pwd)) { + return NextResponse.json( + { error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, + { status: 400 } + ); + } + + const result = await startMitm(apiKey, pwd); + if (!isWin) setCachedPassword(pwd); + + return NextResponse.json({ + success: true, + running: result.running, + pid: result.pid, + }); + } catch (error) { + console.log("Error starting MITM:", error.message); + return NextResponse.json({ error: error.message || "Failed to start MITM proxy" }, { status: 500 }); + } +} + +// DELETE - Stop MITM proxy +export async function DELETE(request) { + try { + const { sudoPassword } = await request.json(); + const isWin = process.platform === "win32"; + const pwd = sudoPassword || getCachedPassword() || ""; + + if (!isWin && !pwd) { + return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); + } + + await stopMitm(pwd); + if (!isWin && sudoPassword) setCachedPassword(sudoPassword); + + return NextResponse.json({ success: true, running: false }); + } catch (error) { + console.log("Error stopping MITM:", error.message); + return NextResponse.json({ error: error.message || "Failed to stop MITM proxy" }, { status: 500 }); + } +} diff --git a/src/app/api/models/alias/route.js b/src/app/api/models/alias/route.js index 3c0d156..30d8ce9 100644 --- a/src/app/api/models/alias/route.js +++ b/src/app/api/models/alias/route.js @@ -24,22 +24,6 @@ export async function PUT(request) { return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); } - const aliases = await getModelAliases(); - - // Check if alias already used by different model - const existingModel = aliases[alias]; - if (existingModel && existingModel !== model) { - return NextResponse.json({ - error: `Alias '${alias}' already in use for model '${existingModel}'` - }, { status: 400 }); - } - - // Delete old alias for this model (if exists and different from new alias) - const oldAlias = Object.entries(aliases).find(([a, m]) => m === model && a !== alias)?.[0]; - if (oldAlias) { - await deleteModelAlias(oldAlias); - } - await setModelAlias(alias, model); await syncToCloudIfEnabled(); diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 481b5df..615d608 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -44,6 +44,7 @@ const defaultData = { providerConnections: [], providerNodes: [], modelAliases: {}, + mitmAlias: {}, combos: [], apiKeys: [], settings: { @@ -59,6 +60,7 @@ function cloneDefaultData() { providerConnections: [], providerNodes: [], modelAliases: {}, + mitmAlias: {}, combos: [], apiKeys: [], settings: { @@ -495,6 +497,22 @@ export async function deleteModelAlias(alias) { await db.write(); } +// ============ MITM Alias ============ + +export async function getMitmAlias(toolName) { + const db = await getDb(); + const all = db.data.mitmAlias || {}; + if (toolName) return all[toolName] || {}; + return all; +} + +export async function setMitmAliasAll(toolName, mappings) { + const db = await getDb(); + if (!db.data.mitmAlias) db.data.mitmAlias = {}; + db.data.mitmAlias[toolName] = mappings || {}; + await db.write(); +} + // ============ Combos ============ /** diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js new file mode 100644 index 0000000..9e59249 --- /dev/null +++ b/src/mitm/cert/generate.js @@ -0,0 +1,44 @@ +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; + +/** + * Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed) + */ +async function generateCert() { + const certDir = path.join(os.homedir(), ".9router", "mitm"); + const keyPath = path.join(certDir, "server.key"); + const certPath = path.join(certDir, "server.crt"); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + console.log("✅ SSL certificate already exists"); + return { key: keyPath, cert: certPath }; + } + + if (!fs.existsSync(certDir)) { + fs.mkdirSync(certDir, { recursive: true }); + } + + const selfsigned = require("selfsigned"); + const attrs = [{ name: "commonName", value: TARGET_HOST }]; + const notAfter = new Date(); + notAfter.setFullYear(notAfter.getFullYear() + 1); + const pems = await selfsigned.generate(attrs, { + keySize: 2048, + algorithm: "sha256", + notAfterDate: notAfter, + extensions: [ + { name: "subjectAltName", altNames: [{ type: 2, value: TARGET_HOST }] } + ] + }); + + fs.writeFileSync(keyPath, pems.private); + fs.writeFileSync(certPath, pems.cert); + + console.log(`✅ Generated SSL certificate for ${TARGET_HOST}`); + return { key: keyPath, cert: certPath }; +} + +module.exports = { generateCert }; diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js new file mode 100644 index 0000000..644add2 --- /dev/null +++ b/src/mitm/cert/install.js @@ -0,0 +1,136 @@ +const fs = require("fs"); +const crypto = require("crypto"); +const { exec } = require("child_process"); +const { execWithPassword } = require("../dns/dnsConfig.js"); + +const IS_WIN = process.platform === "win32"; + +// Get SHA1 fingerprint from cert file using Node.js crypto +function getCertFingerprint(certPath) { + const pem = fs.readFileSync(certPath, "utf-8"); + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64"); + return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":"); +} + +/** + * Check if certificate is already installed in system store + */ +async function checkCertInstalled(certPath) { + if (IS_WIN) { + return checkCertInstalledWindows(certPath); + } + return checkCertInstalledMac(certPath); +} + +function checkCertInstalledMac(certPath) { + return new Promise((resolve) => { + try { + const fingerprint = getCertFingerprint(certPath); + exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error) => { + resolve(!error); + }); + } catch { + resolve(false); + } + }); +} + +function checkCertInstalledWindows(certPath) { + return new Promise((resolve) => { + // Check Root store for our cert by subject name + exec("certutil -store Root daily-cloudcode-pa.googleapis.com", (error) => { + resolve(!error); + }); + }); +} + +/** + * Install SSL certificate to system trust store + */ +async function installCert(sudoPassword, certPath) { + if (!fs.existsSync(certPath)) { + throw new Error(`Certificate file not found: ${certPath}`); + } + + const isInstalled = await checkCertInstalled(certPath); + if (isInstalled) { + console.log("✅ Certificate already installed"); + return; + } + + if (IS_WIN) { + await installCertWindows(certPath); + } else { + await installCertMac(sudoPassword, certPath); + } +} + +async function installCertMac(sudoPassword, certPath) { + const command = `sudo -S security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`; + try { + await execWithPassword(command, sudoPassword); + console.log(`✅ Installed certificate to system keychain: ${certPath}`); + } catch (error) { + const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed"; + throw new Error(msg); + } +} + +async function installCertWindows(certPath) { + // Use PowerShell elevated to add cert to Root store + const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${certPath.replace(/'/g, "''")}' -Verb RunAs -Wait`; + return new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) { + reject(new Error(`Failed to install certificate: ${error.message}`)); + } else { + console.log(`✅ Installed certificate to Windows Root store`); + resolve(); + } + }); + }); +} + +/** + * Uninstall SSL certificate from system store + */ +async function uninstallCert(sudoPassword, certPath) { + const isInstalled = await checkCertInstalled(certPath); + if (!isInstalled) { + console.log("Certificate not found in system store"); + return; + } + + if (IS_WIN) { + await uninstallCertWindows(); + } else { + await uninstallCertMac(sudoPassword, certPath); + } +} + +async function uninstallCertMac(sudoPassword, certPath) { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + const command = `sudo -S security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`; + try { + await execWithPassword(command, sudoPassword); + console.log("✅ Uninstalled certificate from system keychain"); + } catch (err) { + throw new Error("Failed to uninstall certificate"); + } +} + +async function uninstallCertWindows() { + const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','daily-cloudcode-pa.googleapis.com' -Verb RunAs -Wait`; + return new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) { + reject(new Error(`Failed to uninstall certificate: ${error.message}`)); + } else { + console.log("✅ Uninstalled certificate from Windows Root store"); + resolve(); + } + }); + }); +} + +module.exports = { installCert, uninstallCert, checkCertInstalled }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js new file mode 100644 index 0000000..2d62944 --- /dev/null +++ b/src/mitm/dns/dnsConfig.js @@ -0,0 +1,111 @@ +const { exec } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const IS_WIN = process.platform === "win32"; +const HOSTS_FILE = IS_WIN + ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") + : "/etc/hosts"; + +/** + * Execute command with sudo password via stdin (macOS/Linux only) + */ +function execWithPassword(command, password) { + return new Promise((resolve, reject) => { + const child = exec(command, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Command failed: ${error.message}\n${stderr}`)); + } else { + resolve(stdout); + } + }); + child.stdin.write(`${password}\n`); + child.stdin.end(); + }); +} + +/** + * Execute elevated command on Windows via PowerShell RunAs + */ +function execElevatedWindows(command) { + return new Promise((resolve, reject) => { + const psCommand = `Start-Process cmd -ArgumentList '/c','${command.replace(/'/g, "''")}' -Verb RunAs -Wait`; + exec(`powershell -Command "${psCommand}"`, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`)); + } else { + resolve(stdout); + } + }); + }); +} + +/** + * Check if DNS entry already exists + */ +function checkDNSEntry() { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + return hostsContent.includes(TARGET_HOST); + } catch { + return false; + } +} + +/** + * Add DNS entry to hosts file + */ +async function addDNSEntry(sudoPassword) { + if (checkDNSEntry()) { + console.log(`DNS entry for ${TARGET_HOST} already exists`); + return; + } + + const entry = `127.0.0.1 ${TARGET_HOST}`; + + try { + if (IS_WIN) { + // Windows: use elevated echo >> hosts + await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`); + } else { + const command = `echo "${entry}" | sudo -S tee -a ${HOSTS_FILE} > /dev/null`; + await execWithPassword(command, sudoPassword); + } + console.log(`✅ Added DNS entry: ${entry}`); + } catch (error) { + throw new Error(`Failed to add DNS entry: ${error.message}`); + } +} + +/** + * Remove DNS entry from hosts file + */ +async function removeDNSEntry(sudoPassword) { + if (!checkDNSEntry()) { + console.log(`DNS entry for ${TARGET_HOST} does not exist`); + return; + } + + try { + if (IS_WIN) { + // Windows: read, filter, write back via elevated PowerShell + const psScript = `(Get-Content '${HOSTS_FILE}') | Where-Object { $_ -notmatch '${TARGET_HOST}' } | Set-Content '${HOSTS_FILE}'`; + const psCommand = `Start-Process powershell -ArgumentList '-Command','${psScript.replace(/'/g, "''")}' -Verb RunAs -Wait`; + await new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`)); + else resolve(); + }); + }); + } else { + const command = `sudo -S sed -i '' '/${TARGET_HOST}/d' ${HOSTS_FILE}`; + await execWithPassword(command, sudoPassword); + } + console.log(`✅ Removed DNS entry for ${TARGET_HOST}`); + } catch (error) { + throw new Error(`Failed to remove DNS entry: ${error.message}`); + } +} + +module.exports = { addDNSEntry, removeDNSEntry, execWithPassword, checkDNSEntry }; diff --git a/src/mitm/manager.js b/src/mitm/manager.js new file mode 100644 index 0000000..901fca5 --- /dev/null +++ b/src/mitm/manager.js @@ -0,0 +1,227 @@ +const { spawn } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const { addDNSEntry, removeDNSEntry } = require("./dns/dnsConfig"); +const { generateCert } = require("./cert/generate"); +const { installCert } = require("./cert/install"); + +// Store server process +let serverProcess = null; +let serverPid = null; +// Persist across Next.js hot reloads +function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } +function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } + +// server.js is in same directory as this file +const PID_FILE = path.join(os.homedir(), ".9router", "mitm", ".mitm.pid"); + +// Check if a PID is alive +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Get MITM status + */ +async function getMitmStatus() { + // Check in-memory process first, then fallback to PID file + let running = serverProcess !== null && !serverProcess.killed; + let pid = serverPid; + + if (!running) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + running = true; + pid = savedPid; + } else { + // Stale PID file, clean up + fs.unlinkSync(PID_FILE); + } + } + } catch { + // Ignore + } + } + + // Check DNS configuration + let dnsConfigured = false; + try { + const hostsContent = fs.readFileSync("/etc/hosts", "utf-8"); + dnsConfigured = hostsContent.includes("daily-cloudcode-pa.googleapis.com"); + } catch { + // Ignore + } + + // Check cert + const certDir = path.join(os.homedir(), ".9router", "mitm"); + const certExists = fs.existsSync(path.join(certDir, "server.crt")); + + return { running, pid, dnsConfigured, certExists }; +} + +/** + * Start MITM proxy + * @param {string} apiKey - 9Router API key + * @param {string} sudoPassword - Sudo password for DNS/cert operations + */ +async function startMitm(apiKey, sudoPassword) { + // Check if already running + if (serverProcess && !serverProcess.killed) { + throw new Error("MITM proxy is already running"); + } + + // 1. Generate SSL certificate if not exists + const certPath = path.join(os.homedir(), ".9router", "mitm", "server.crt"); + if (!fs.existsSync(certPath)) { + console.log("Generating SSL certificate..."); + await generateCert(); + } + + // 2. Install certificate to system keychain + await installCert(sudoPassword, certPath); + + // 3. Add DNS entry + console.log("Adding DNS entry..."); + await addDNSEntry(sudoPassword); + + // 4. Start MITM server + console.log("Starting MITM server..."); + const serverPath = path.join(process.cwd(), "src/mitm/server.js"); + serverProcess = spawn("node", [serverPath], { + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production" + }, + detached: false, + stdio: ["ignore", "pipe", "pipe"] + }); + + serverPid = serverProcess.pid; + + // Save PID to file + fs.writeFileSync(PID_FILE, String(serverPid)); + + // Log server output + serverProcess.stdout.on("data", (data) => { + console.log(`[MITM Server] ${data.toString().trim()}`); + }); + + serverProcess.stderr.on("data", (data) => { + console.error(`[MITM Server Error] ${data.toString().trim()}`); + }); + + serverProcess.on("exit", (code) => { + console.log(`MITM server exited with code ${code}`); + serverProcess = null; + serverPid = null; + + // Remove PID file + try { + fs.unlinkSync(PID_FILE); + } catch (error) { + // Ignore + } + }); + + // Wait and verify server actually started + const started = await new Promise((resolve) => { + let resolved = false; + const timeout = setTimeout(() => { + if (!resolved) { resolved = true; resolve(true); } + }, 2000); + + serverProcess.on("exit", (code) => { + clearTimeout(timeout); + if (!resolved) { resolved = true; resolve(false); } + }); + + // Check stderr for error messages + serverProcess.stderr.on("data", (data) => { + const msg = data.toString().trim(); + if (msg.includes("Port") && msg.includes("already in use")) { + clearTimeout(timeout); + if (!resolved) { resolved = true; resolve(false); } + } + }); + }); + + if (!started) { + throw new Error("MITM server failed to start (port 443 may be in use)"); + } + + return { + running: true, + pid: serverPid + }; +} + +/** + * Stop MITM proxy + * @param {string} sudoPassword - Sudo password for DNS cleanup + */ +async function stopMitm(sudoPassword) { + // 1. Kill server process (in-memory or from PID file) + const proc = serverProcess; + if (proc && !proc.killed) { + console.log("Stopping MITM server..."); + proc.kill("SIGTERM"); + await new Promise(resolve => setTimeout(resolve, 1000)); + if (!proc.killed) { + proc.kill("SIGKILL"); + } + serverProcess = null; + serverPid = null; + } else { + // Fallback: kill by PID file + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + console.log(`Killing MITM server (PID: ${savedPid})...`); + process.kill(savedPid, "SIGTERM"); + await new Promise(resolve => setTimeout(resolve, 1000)); + if (isProcessAlive(savedPid)) { + process.kill(savedPid, "SIGKILL"); + } + } + } + } catch { + // Ignore + } + serverProcess = null; + serverPid = null; + } + + // 2. Remove DNS entry + console.log("Removing DNS entry..."); + await removeDNSEntry(sudoPassword); + + // 3. Remove PID file + try { + fs.unlinkSync(PID_FILE); + } catch (error) { + // Ignore + } + + return { + running: false, + pid: null + }; +} + +module.exports = { + getMitmStatus, + startMitm, + stopMitm, + getCachedPassword, + setCachedPassword +}; diff --git a/src/mitm/server.js b/src/mitm/server.js new file mode 100644 index 0000000..3a0299c --- /dev/null +++ b/src/mitm/server.js @@ -0,0 +1,214 @@ +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const dns = require("dns"); +const { promisify } = require("util"); +const os = require("os"); + +// Configuration +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const LOCAL_PORT = 443; +const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; +const API_KEY = process.env.ROUTER_API_KEY; +const DB_FILE = path.join(os.homedir(), ".9router", "db.json"); + +// Toggle logging (set true to enable file logging for debugging) +const ENABLE_FILE_LOG = false; + +if (!API_KEY) { + console.error("❌ ROUTER_API_KEY required"); + process.exit(1); +} + +// Load SSL certificates +const certDir = path.join(os.homedir(), ".9router", "mitm"); +const sslOptions = { + key: fs.readFileSync(path.join(certDir, "server.key")), + cert: fs.readFileSync(path.join(certDir, "server.crt")) +}; + +// Chat endpoints that should be intercepted +const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; + +// Log directory for request/response dumps +const LOG_DIR = path.join(__dirname, "../../logs/mitm"); +if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + +function saveRequestLog(url, bodyBuffer) { + if (!ENABLE_FILE_LOG) return; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); + const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`); + const body = JSON.parse(bodyBuffer.toString()); + fs.writeFileSync(filePath, JSON.stringify(body, null, 2)); + console.log(`💾 Saved request: ${filePath}`); + } catch { + // Ignore + } +} + +function saveResponseLog(url, data) { + if (!ENABLE_FILE_LOG) return; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); + const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`); + fs.writeFileSync(filePath, data); + console.log(`💾 Saved response: ${filePath}`); + } catch { + // Ignore + } +} + +// Resolve real IP of target host (bypass /etc/hosts) +let cachedTargetIP = null; +async function resolveTargetIP() { + if (cachedTargetIP) return cachedTargetIP; + const resolver = new dns.Resolver(); + resolver.setServers(["8.8.8.8"]); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(TARGET_HOST); + cachedTargetIP = addresses[0]; + return cachedTargetIP; +} + +function collectBodyRaw(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function extractModel(body) { + try { + return JSON.parse(body.toString()).model || null; + } catch { + return null; + } +} + +function getMappedModel(model) { + if (!model) return null; + try { + const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); + return db.mitmAlias?.antigravity?.[model] || null; + } catch { + return null; + } +} + +async function passthrough(req, res, bodyBuffer) { + const targetIP = await resolveTargetIP(); + + const forwardReq = https.request({ + hostname: targetIP, + port: 443, + path: req.url, + method: req.method, + headers: { ...req.headers, host: TARGET_HOST }, + servername: TARGET_HOST, + rejectUnauthorized: false + }, (forwardRes) => { + res.writeHead(forwardRes.statusCode, forwardRes.headers); + forwardRes.pipe(res); + }); + + forwardReq.on("error", (err) => { + console.error(`❌ Passthrough error: ${err.message}`); + if (!res.headersSent) res.writeHead(502); + res.end("Bad Gateway"); + }); + + if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer); + forwardReq.end(); +} + +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + + const response = await fetch(ROUTER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${API_KEY}` + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error(`9Router ${response.status}: ${errText}`); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { res.end(); break; } + res.write(decoder.decode(value, { stream: true })); + } + } catch (error) { + console.error(`❌ ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +const server = https.createServer(sslOptions, async (req, res) => { + const bodyBuffer = await collectBodyRaw(req); + + // Save request log if enabled + if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); + + // Anti-loop: requests from 9Router bypass interception + if (req.headers["x-9router-source"] === "9router") { + return passthrough(req, res, bodyBuffer); + } + + const isChatRequest = CHAT_URL_PATTERNS.some(p => req.url.includes(p)); + + if (!isChatRequest) { + return passthrough(req, res, bodyBuffer); + } + + const model = extractModel(bodyBuffer); + const mappedModel = getMappedModel(model); + + if (!mappedModel) { + return passthrough(req, res, bodyBuffer); + } + + console.log(`🔀 ${model} → ${mappedModel}`); + return intercept(req, res, bodyBuffer, mappedModel); +}); + +server.listen(LOCAL_PORT, () => { + console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`); +}); + +server.on("error", (error) => { + if (error.code === "EADDRINUSE") { + console.error(`❌ Port ${LOCAL_PORT} already in use`); + } else if (error.code === "EACCES") { + console.error(`❌ Permission denied for port ${LOCAL_PORT}`); + } else { + console.error(`❌ ${error.message}`); + } + process.exit(1); +}); + +process.on("SIGTERM", () => { server.close(() => process.exit(0)); }); +process.on("SIGINT", () => { server.close(() => process.exit(0)); }); diff --git a/src/models/index.js b/src/models/index.js index 84fa2ab..637b6f3 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -14,6 +14,8 @@ export { getModelAliases, setModelAlias, deleteModelAlias, + getMitmAlias, + setMitmAliasAll, getApiKeys, createApiKey, deleteApiKey, diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 631aed3..e268520 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -121,6 +121,22 @@ export const CLI_TOOLS = { }`, }, }, + antigravity: { + id: "antigravity", + name: "Antigravity", + image: "/providers/antigravity.png", + color: "#4285F4", + description: "Google Antigravity IDE with MITM", + configType: "mitm", + modelAliases: ["claude-opus-4-5-thinking", "claude-sonnet-4-5-thinking", "claude-sonnet-4-5", "gemini-3-pro-high"], + defaultModels: [ + { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking", alias: "claude-opus-4-5-thinking" }, + { id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking", alias: "claude-sonnet-4-5-thinking" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" }, + { id: "gemini-3-pro-high", name: "Gemini 3 Pro High", alias: "gemini-3-pro-high" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",