feat(antigravity): integrate Antigravity tool with MITM support and update CLI tools

This commit is contained in:
decolua
2026-02-08 16:28:13 +07:00
parent 18712b24cf
commit 2e854bd4c9
21 changed files with 1680 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <DroidToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "openclaw":
return <OpenClawToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "antigravity":
return <AntigravityToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
default:
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
}

View File

@@ -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 (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/antigravity.png"
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{isRunning ? (
<Badge variant="success" size="sm">Active</Badge>
) : (
<Badge variant="default" size="sm">Inactive</Badge>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{/* Start/Stop Button - always on top */}
<div className="flex items-center gap-2">
{isRunning ? (
<button
onClick={handleStop}
disabled={loading}
className="px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 font-medium text-sm flex items-center gap-2 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]">stop_circle</span>
Stop MITM
</button>
) : (
<button
onClick={handleStart}
disabled={loading || !hasActiveProviders}
className="px-4 py-2 rounded-lg bg-primary/10 border border-primary/30 text-primary font-medium text-sm flex items-center gap-2 hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[18px]">play_circle</span>
Start MITM
</button>
)}
</div>
{message?.type === "error" && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600">
<span className="material-symbols-outlined text-[14px]">error</span>
<span>{message.text}</span>
</div>
)}
{/* When running: API Key + Model Mappings */}
{isRunning && (
<>
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">API Key</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
</div>
{tool.defaultModels.map((model) => (
<div key={model.alias} className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">{model.name}</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => 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"
/>
<button
onClick={() => openModelSelector(model.alias)}
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
{modelMappings[model.alias] && (
<button
onClick={() => handleModelMappingChange(model.alias, "")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
))}
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={handleSaveMappings}
disabled={loading || Object.keys(modelMappings).length === 0}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
Save Mappings
</Button>
</div>
</>
)}
{/* When stopped: how it works */}
{!isRunning && (
<div className="flex flex-col gap-1.5 px-1">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">How it works:</span> Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.
</p>
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
<span>1. Generates SSL cert & adds to system keychain</span>
<span>2. Redirects <code className="text-[10px] bg-surface px-1 rounded">daily-cloudcode-pa.googleapis.com</code> localhost</span>
<span>3. Maps Antigravity models to any provider via 9Router</span>
</div>
</div>
)}
</div>
)}
{/* Password Modal */}
<Modal
isOpen={showPasswordModal}
onClose={() => {
setShowPasswordModal(false);
setSudoPassword("");
setMessage(null);
}}
title="Sudo Password Required"
size="sm"
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<p className="text-xs text-text-muted">Required for SSL certificate and DNS configuration</p>
</div>
<Input
type="password"
placeholder="Enter sudo password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !loading) handleConfirmPassword();
}}
/>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => { setShowPasswordModal(false); setSudoPassword(""); setMessage(null); }}
disabled={loading}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleConfirmPassword}
loading={loading}
>
Confirm
</Button>
</div>
</div>
</Modal>
{/* Model Select Modal */}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
title={`Select model for ${currentEditingAlias}`}
/>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ============
/**

44
src/mitm/cert/generate.js Normal file
View File

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

136
src/mitm/cert/install.js Normal file
View File

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

111
src/mitm/dns/dnsConfig.js Normal file
View File

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

227
src/mitm/manager.js Normal file
View File

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

214
src/mitm/server.js Normal file
View File

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

View File

@@ -14,6 +14,8 @@ export {
getModelAliases,
setModelAlias,
deleteModelAlias,
getMitmAlias,
setMitmAliasAll,
getApiKeys,
createApiKey,
deleteApiKey,

View File

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