mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat(antigravity): integrate Antigravity tool with MITM support and update CLI tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
223
open-sse/translator/request/antigravity-to-openai.js
Normal file
223
open-sse/translator/request/antigravity-to-openai.js
Normal 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);
|
||||
@@ -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) {
|
||||
|
||||
120
open-sse/translator/response/openai-to-antigravity.js
Normal file
120
open-sse/translator/response/openai-to-antigravity.js
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
41
src/app/api/cli-tools/antigravity-mitm/alias/route.js
Normal file
41
src/app/api/cli-tools/antigravity-mitm/alias/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/cli-tools/antigravity-mitm/route.js
Normal file
70
src/app/api/cli-tools/antigravity-mitm/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
44
src/mitm/cert/generate.js
Normal 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
136
src/mitm/cert/install.js
Normal 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
111
src/mitm/dns/dnsConfig.js
Normal 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
227
src/mitm/manager.js
Normal 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
214
src/mitm/server.js
Normal 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)); });
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
getModelAliases,
|
||||
setModelAlias,
|
||||
deleteModelAlias,
|
||||
getMitmAlias,
|
||||
setMitmAliasAll,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user