mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
This commit is contained in:
committed by
GitHub
parent
ea4bbdf7c0
commit
d91f7d1c3f
@@ -311,6 +311,7 @@ export default function AgentBridgePageClient({
|
||||
targets={targets}
|
||||
agentStates={data.agentStates}
|
||||
serverRunning={data.serverState.running}
|
||||
serverState={data.serverState}
|
||||
mappingsMap={data.mappings}
|
||||
onDnsToggle={handleDnsToggle}
|
||||
onMappingsSave={handleMappingsSave}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ModelMappingTable } from "./ModelMappingTable";
|
||||
import { SetupWizard } from "./SetupWizard";
|
||||
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { AgentStateEntry, AgentBridgeServerState } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
const RISK_STORAGE_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
|
||||
@@ -26,6 +26,7 @@ interface AgentCardProps {
|
||||
target: MitmTargetView;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
serverState: AgentBridgeServerState;
|
||||
mappings: MappingRow[];
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
|
||||
@@ -38,6 +39,7 @@ export function AgentCard({
|
||||
target,
|
||||
agentState,
|
||||
serverRunning,
|
||||
serverState,
|
||||
mappings,
|
||||
onDnsToggle,
|
||||
onMappingsSave,
|
||||
@@ -50,7 +52,9 @@ export function AgentCard({
|
||||
|
||||
const dnsEnabled = agentState?.dns_enabled ?? false;
|
||||
const setupCompleted = agentState?.setup_completed ?? false;
|
||||
const certTrusted = agentState?.cert_trusted ?? false;
|
||||
// Fix #8656 Issue A: Use server-level cert trust as fallback
|
||||
// (one server cert applies to all agents; agentState.cert_trusted is never written to DB)
|
||||
const certTrusted = agentState?.cert_trusted ?? serverState.certTrusted ?? false;
|
||||
const isInvestigating = target.viability === "investigating";
|
||||
|
||||
const getStatusBadge = () => {
|
||||
@@ -250,8 +254,11 @@ export function AgentCard({
|
||||
target={target}
|
||||
agentState={agentState}
|
||||
serverRunning={serverRunning}
|
||||
serverState={serverState}
|
||||
currentMappings={mappings}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onDnsToggle={onDnsToggle}
|
||||
onMappingsSave={onMappingsSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AgentCard } from "./AgentCard";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
|
||||
import type { AgentStateEntry, AgentMappingsMap, AgentBridgeServerState } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
interface AgentListProps {
|
||||
targets: MitmTargetView[];
|
||||
agentStates: AgentStateEntry[];
|
||||
serverRunning: boolean;
|
||||
serverState: AgentBridgeServerState;
|
||||
mappingsMap: AgentMappingsMap;
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
|
||||
@@ -26,6 +27,7 @@ export function AgentList({
|
||||
targets,
|
||||
agentStates,
|
||||
serverRunning,
|
||||
serverState,
|
||||
mappingsMap,
|
||||
onDnsToggle,
|
||||
onMappingsSave,
|
||||
@@ -130,6 +132,7 @@ export function AgentList({
|
||||
target={target}
|
||||
agentState={stateByAgent[target.id]}
|
||||
serverRunning={serverRunning}
|
||||
serverState={serverState}
|
||||
mappings={mappingsMap[target.id] ?? []}
|
||||
onDnsToggle={onDnsToggle}
|
||||
onMappingsSave={onMappingsSave}
|
||||
|
||||
@@ -29,6 +29,18 @@ export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTab
|
||||
setSelectorOpen(null);
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
setRows((prev) => [...prev, { source: "", target: "" }]);
|
||||
};
|
||||
|
||||
const removeMapping = (index: number) => {
|
||||
setRows((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateSource = (index: number, source: string) => {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, source } : r)));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -38,66 +50,101 @@ export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTab
|
||||
}
|
||||
};
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("noMappings") || "No model mappings configured. Run setup wizard to auto-detect models."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="rounded-lg border border-border/40 overflow-hidden bg-surface">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-surface/60">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("sourceModel") || "Source model (agent native)"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("targetModel") || "Target model (OmniRoute)"}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/20 last:border-0">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-mono text-xs text-text-muted">{row.source}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectorOpen(i)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-card px-2.5 py-1 text-xs hover:bg-surface transition-colors font-mono"
|
||||
>
|
||||
{row.target || (
|
||||
<span className="text-text-muted italic">
|
||||
{t("selectModel") || "Select…"}
|
||||
</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted">
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-border/40 bg-surface/30 px-4 py-6 text-center">
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
{t("noMappingsDesc") || "No model mappings configured yet. Add mappings to route agent requests through OmniRoute."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMapping}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 text-primary px-3 py-1.5 text-xs font-medium hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
{t("addMapping") || "Add mapping"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-border/40 overflow-hidden bg-surface">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-surface/60">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("sourceModel") || "Source model (agent native)"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("targetModel") || "Target model (OmniRoute)"}
|
||||
</th>
|
||||
<th className="px-3 py-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/20 last:border-0">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={row.source}
|
||||
onChange={(e) => updateSource(i, e.target.value)}
|
||||
placeholder="e.g., gpt-4"
|
||||
className="w-full rounded border border-border/40 bg-card px-2 py-1 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectorOpen(i)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-card px-2.5 py-1 text-xs hover:bg-surface transition-colors font-mono w-full justify-between"
|
||||
>
|
||||
{row.target || (
|
||||
<span className="text-text-muted italic">
|
||||
{t("selectModel") || "Select…"}
|
||||
</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted">
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMapping(i)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
aria-label="Remove mapping"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary/10 text-primary px-4 py-1.5 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? t("saving") || "Saving…" : t("saveMappings") || "Save mappings"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMapping}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-card px-3 py-1.5 text-xs font-medium hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
{t("addMapping") || "Add mapping"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary/10 text-primary px-4 py-1.5 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? t("saving") || "Saving…" : t("saveMappings") || "Save mappings"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectorOpen !== null && (
|
||||
<ModelSelectorModal
|
||||
|
||||
@@ -2,19 +2,28 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { AgentStateEntry, AgentBridgeServerState } from "../AgentBridgePageClient";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
|
||||
interface SetupWizardProps {
|
||||
target: MitmTargetView;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
serverState: AgentBridgeServerState;
|
||||
currentMappings: { source: string; target: string }[]; // Current mappings for this agent
|
||||
onClose: () => void;
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
onMappingsSave: (agentId: string, mappings: { source: string; target: string }[]) => Promise<void>;
|
||||
}
|
||||
|
||||
type Step = "verify" | "dns" | "mappings";
|
||||
|
||||
interface DetectedModelsResponse {
|
||||
agentId: string;
|
||||
detectedModels: string[];
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-step setup wizard for a single agent.
|
||||
* Step 1: Verify server + cert
|
||||
@@ -25,13 +34,19 @@ export function SetupWizard({
|
||||
target,
|
||||
agentState,
|
||||
serverRunning,
|
||||
serverState,
|
||||
currentMappings,
|
||||
onClose,
|
||||
onDnsToggle,
|
||||
onMappingsSave,
|
||||
}: SetupWizardProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const tc = useTranslations("common");
|
||||
const [step, setStep] = useState<Step>("verify");
|
||||
const [enablingDns, setEnablingDns] = useState(false);
|
||||
const [detectedModels, setDetectedModels] = useState<string[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -41,7 +56,26 @@ export function SetupWizard({
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const certTrusted = agentState?.cert_trusted ?? false;
|
||||
// Fetch detected models when we reach the mappings step
|
||||
useEffect(() => {
|
||||
if (step === "mappings") {
|
||||
setLoadingModels(true);
|
||||
fetch(`/api/tools/agent-bridge/agents/${target.id}/detected-models`)
|
||||
.then((res) => res.json())
|
||||
.then((data: DetectedModelsResponse) => {
|
||||
setDetectedModels(data.detectedModels || []);
|
||||
})
|
||||
.catch(() => {
|
||||
setDetectedModels([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingModels(false);
|
||||
});
|
||||
}
|
||||
}, [step, target.id]);
|
||||
|
||||
// Fix #8656 Issue A: Use server-level cert trust as fallback
|
||||
const certTrusted = agentState?.cert_trusted ?? serverState.certTrusted ?? false;
|
||||
const dnsEnabled = agentState?.dns_enabled ?? false;
|
||||
|
||||
const handleEnableDns = async () => {
|
||||
@@ -54,6 +88,44 @@ export function SetupWizard({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModelSelection = (model: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(model)) {
|
||||
next.delete(model);
|
||||
} else {
|
||||
next.add(model);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSelectedModels = async () => {
|
||||
if (selectedModels.size === 0) return;
|
||||
|
||||
// Merge detected models with existing mappings instead of replacing
|
||||
// Filter out models that already exist in current mappings
|
||||
const existingSources = new Set(currentMappings.map((m) => m.source));
|
||||
const newMappings = Array.from(selectedModels)
|
||||
.filter((source) => !existingSources.has(source)) // Only add new ones
|
||||
.map((source) => ({
|
||||
source,
|
||||
target: "", // Will be selected later in the main card
|
||||
}));
|
||||
|
||||
// Combine existing + new mappings
|
||||
const allMappings = [...currentMappings, ...newMappings];
|
||||
|
||||
try {
|
||||
await onMappingsSave(target.id, allMappings);
|
||||
// Wait a bit for the parent to refresh state before closing
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
onClose();
|
||||
} catch {
|
||||
// Error handling in parent component
|
||||
}
|
||||
};
|
||||
|
||||
const steps: { id: Step; label: string }[] = [
|
||||
{ id: "verify", label: t("wizardStep1Label") },
|
||||
{ id: "dns", label: t("wizardStep2Label") },
|
||||
@@ -192,7 +264,49 @@ export function SetupWizard({
|
||||
<span className="material-symbols-outlined text-[20px]">check_circle</span>
|
||||
<p className="text-sm font-medium">{t("wizardStep3Success")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">{t("wizardStep3Desc")}</p>
|
||||
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px] animate-spin">progress_activity</span>
|
||||
Detecting models from intercepted traffic...
|
||||
</div>
|
||||
) : detectedModels.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-text-muted">
|
||||
Found {detectedModels.length} model{detectedModels.length !== 1 ? "s" : ""} in intercepted traffic. Select the ones you want to add:
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/40 bg-surface p-3 flex flex-col gap-2 max-h-[200px] overflow-y-auto">
|
||||
{detectedModels.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-surface/50 p-2 rounded transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModels.has(model)}
|
||||
onChange={() => toggleModelSelection(model)}
|
||||
className="rounded border-border/50 text-primary focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<span className="font-mono text-xs text-text-main">{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedModels.size > 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected. You'll map them to OmniRoute models in the next screen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/40 bg-surface/30 p-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
No models detected yet. Use {target.name} to make a request, then run this wizard again to auto-detect models from traffic.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
Or close this wizard and add mappings manually in the agent card.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,13 +361,25 @@ export function SetupWizard({
|
||||
)}
|
||||
|
||||
{step === "mappings" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-400 transition-colors"
|
||||
>
|
||||
{t("done")}
|
||||
</button>
|
||||
<>
|
||||
{detectedModels.length > 0 && selectedModels.size > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSelectedModels}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add {selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-400 transition-colors"
|
||||
>
|
||||
{t("done")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* GET /api/tools/agent-bridge/agents/[id]/detected-models
|
||||
*
|
||||
* Returns unique source models detected from intercepted traffic for the given agent.
|
||||
* Used by Setup Wizard to auto-suggest model mappings.
|
||||
*
|
||||
* LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts.
|
||||
*/
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
import type { AgentId } from "@/mitm/types";
|
||||
|
||||
const VALID_IDS = new Set<AgentId>([
|
||||
"antigravity",
|
||||
"kiro",
|
||||
"copilot",
|
||||
"codex",
|
||||
"cursor",
|
||||
"zed",
|
||||
"claude-code",
|
||||
"open-code",
|
||||
"trae",
|
||||
"windsurf",
|
||||
"jules",
|
||||
]);
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
if (!VALID_IDS.has(id as AgentId)) {
|
||||
return createErrorResponse({ status: 404, message: `Unknown agent id: ${id}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const agentId = id as AgentId;
|
||||
|
||||
// Get all intercepted requests for this agent
|
||||
const allRequests = globalTrafficBuffer.list();
|
||||
const agentRequests = allRequests.filter(
|
||||
(req) => req.source === "agent-bridge" && req.agent === agentId
|
||||
);
|
||||
|
||||
// Extract unique source models (filter out nulls/undefined)
|
||||
const uniqueModels = new Set<string>();
|
||||
for (const req of agentRequests) {
|
||||
if (req.sourceModel && typeof req.sourceModel === "string") {
|
||||
uniqueModels.add(req.sourceModel);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically for consistent ordering
|
||||
const models = Array.from(uniqueModels).sort();
|
||||
|
||||
return Response.json({
|
||||
agentId,
|
||||
detectedModels: models,
|
||||
requestCount: agentRequests.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,16 @@
|
||||
* LOCAL_ONLY: registered in routeGuard.ts
|
||||
*/
|
||||
import { AgentBridgeMappingPutSchema } from "@/shared/schemas/agentBridge";
|
||||
import { getMappingsForAgent, setMappings } from "@/lib/db/agentBridgeMappings";
|
||||
import { getMappingsForAgent, setMappings, syncAgentBridgeMappingsToMitmAlias } from "@/lib/db/agentBridgeMappings";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
type Params = { params: { id: string } };
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Params): Promise<Response> {
|
||||
try {
|
||||
const mappings = getMappingsForAgent(params.id);
|
||||
const { id } = await params;
|
||||
const mappings = getMappingsForAgent(id);
|
||||
return Response.json({ mappings });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
@@ -38,8 +39,12 @@ export async function PUT(request: Request, { params }: Params): Promise<Respons
|
||||
}
|
||||
|
||||
try {
|
||||
setMappings(params.id, parsed.data.mappings);
|
||||
const mappings = getMappingsForAgent(params.id);
|
||||
const { id } = await params;
|
||||
setMappings(id, parsed.data.mappings);
|
||||
// Fix #8656: Sync to mitmAlias in key_value table so the MITM proxy
|
||||
// (server.cjs) reads user-configured mappings during interception.
|
||||
syncAgentBridgeMappingsToMitmAlias(id);
|
||||
const mappings = getMappingsForAgent(id);
|
||||
return Response.json({ ok: true, mappings });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* `healthy` verdict). Answers "why is nothing being captured?" in one call.
|
||||
*
|
||||
* LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts.
|
||||
*
|
||||
* Fix #8656 follow-up: Compute aggregate dnsConfigured when no agentId provided
|
||||
* (matches state route behavior for consistency).
|
||||
*/
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
@@ -18,6 +21,8 @@ import { getMitmStatus } from "@/mitm/manager";
|
||||
import { checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { summarizeDiagnostics } from "@/mitm/inspector/diagnostics";
|
||||
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
|
||||
import { checkDNSEntryForAgent } from "@/mitm/dns/dnsConfig";
|
||||
|
||||
/** Best-effort TCP reachability probe; resolves false on error/timeout. */
|
||||
function probeTcp(port: number, host = "127.0.0.1", timeoutMs = 1500): Promise<boolean> {
|
||||
@@ -48,12 +53,23 @@ export async function GET(request: Request): Promise<Response> {
|
||||
Number(process.env.MITM_LOCAL_PORT) > 0 ? Number(process.env.MITM_LOCAL_PORT) : 443;
|
||||
const serverReachable = status.running ? await probeTcp(port) : false;
|
||||
|
||||
// Compute aggregate dnsConfigured when no agentId provided (matches state route)
|
||||
// This fixes diagnose showing DNS ❌ for non-Antigravity agents (Kiro, Codex, Cursor)
|
||||
let dnsConfigured = status.dnsConfigured;
|
||||
if (!agentId) {
|
||||
// Check if ANY agent has DNS configured (aggregate view)
|
||||
const agentStates = await getAllAgentBridgeStates();
|
||||
dnsConfigured =
|
||||
agentStates.length > 0 &&
|
||||
agentStates.some((s) => s.dns_enabled && checkDNSEntryForAgent(s.agent_id));
|
||||
}
|
||||
|
||||
const report = summarizeDiagnostics({
|
||||
serverRunning: status.running,
|
||||
serverReachable,
|
||||
certExists,
|
||||
certTrusted,
|
||||
dnsConfigured: status.dnsConfigured,
|
||||
dnsConfigured,
|
||||
});
|
||||
|
||||
return Response.json({ ...report, port });
|
||||
|
||||
@@ -2,21 +2,82 @@
|
||||
* GET /api/tools/agent-bridge/state
|
||||
* Returns global MITM server status + per-agent detection/status.
|
||||
* LOCAL_ONLY: registered in routeGuard.ts
|
||||
*
|
||||
* Fix #8656: Now returns the full payload shape the UI expects:
|
||||
* { serverState, agentStates, bypassPatterns, mappings } while maintaining
|
||||
* backward-compat legacy keys { server, agents } for integration tests.
|
||||
*/
|
||||
import { getMitmStatus, getAllAgentsStatus, getCachedPassword } from "@/mitm/manager";
|
||||
import { isSudoPasswordRequired } from "@/mitm/dns/dnsConfig";
|
||||
import { isSudoPasswordRequired, checkDNSEntryForAgent } from "@/mitm/dns/dnsConfig";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
|
||||
import { getAllBypassPatterns } from "@/lib/db/agentBridgeBypass";
|
||||
import { getMappingsForAgent } from "@/lib/db/agentBridgeMappings";
|
||||
import { checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { ALL_TARGETS } from "@/mitm/targets/index";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const [server, agents] = await Promise.all([getMitmStatus(), getAllAgentsStatus()]);
|
||||
// Fetch all data in parallel for performance
|
||||
const [serverStatus, agents, agentStates, bypassPatterns] = await Promise.all([
|
||||
getMitmStatus(),
|
||||
getAllAgentsStatus(),
|
||||
getAllAgentBridgeStates(),
|
||||
getAllBypassPatterns(),
|
||||
]);
|
||||
|
||||
// Load mappings for all registered agents
|
||||
const mappingsEntries = await Promise.all(
|
||||
ALL_TARGETS.map(async (t) => {
|
||||
const mappings = getMappingsForAgent(t.id);
|
||||
return [
|
||||
t.id,
|
||||
mappings.map((m) => ({ source: m.source_model, target: m.target_model })),
|
||||
] as const;
|
||||
})
|
||||
);
|
||||
const mappings = Object.fromEntries(mappingsEntries);
|
||||
|
||||
// Compute REAL certTrusted (OS trust store check, not just file exists)
|
||||
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
||||
const certPath = path.join(certDir, "server.crt");
|
||||
const certExists = fs.existsSync(certPath);
|
||||
const certTrusted = certExists ? await checkCertInstalled(certPath) : false;
|
||||
|
||||
// Compute aggregate dnsConfigured: true if ANY agent has hosts spoofed
|
||||
// This fixes the Maintenance card "dns-configured" showing ❌ for non-Antigravity agents
|
||||
const dnsConfigured =
|
||||
agentStates.length > 0 &&
|
||||
agentStates.some((s) => s.dns_enabled && checkDNSEntryForAgent(s.agent_id));
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const hasCachedPassword = !!getCachedPassword();
|
||||
const needsSudoPassword = !isWin && !hasCachedPassword && isSudoPasswordRequired();
|
||||
|
||||
// Build enriched server state
|
||||
const enrichedServer = {
|
||||
...serverStatus,
|
||||
certExists,
|
||||
certTrusted,
|
||||
dnsConfigured,
|
||||
hasCachedPassword,
|
||||
needsSudoPassword,
|
||||
isWin,
|
||||
};
|
||||
|
||||
return Response.json({
|
||||
server: { ...server, hasCachedPassword, needsSudoPassword, isWin },
|
||||
// Legacy keys for backward compat (integration tests + settings/mitm depend on these)
|
||||
server: enrichedServer,
|
||||
agents,
|
||||
// New keys the UI actually reads (fix #8656)
|
||||
serverState: enrichedServer,
|
||||
agentStates,
|
||||
bypassPatterns: bypassPatterns.map((b) => b.pattern),
|
||||
mappings,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -10778,6 +10778,8 @@
|
||||
"sourceModel": "Source model (agent native)",
|
||||
"targetModel": "Target model (OmniRoute)",
|
||||
"noMappings": "No model mappings configured. Run setup wizard to auto-detect models.",
|
||||
"noMappingsDesc": "No model mappings configured yet. Add mappings to route agent requests through OmniRoute.",
|
||||
"addMapping": "Add mapping",
|
||||
"selectModel": "Select…",
|
||||
"saveMappings": "Save mappings",
|
||||
"setupWizard": "Setup wizard",
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
import type { AgentBridgeMappingRow } from "./_rowTypes";
|
||||
import { setMitmAliasAll } from "./models/mitmAlias";
|
||||
|
||||
/** Agents that have a registered alias key in standaloneRouting.cjs::AGENT_ROUTE_CONFIG. */
|
||||
const MITM_ALIAS_AGENTS = new Set(["antigravity", "claude-code", "kiro"]);
|
||||
|
||||
export function getMappingsForAgent(agentId: string): AgentBridgeMappingRow[] {
|
||||
const db = getDbInstance();
|
||||
@@ -45,3 +49,27 @@ export function deleteMapping(agentId: string, source: string): void {
|
||||
"DELETE FROM agent_bridge_mappings WHERE agent_id = ? AND source_model = ?"
|
||||
).run(agentId, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync agent_bridge_mappings for the given agent to the key_value table as
|
||||
* mitmAlias entries so the MITM proxy (server.cjs) can read them during
|
||||
* interception.
|
||||
*
|
||||
* Only syncs agents that have a dedicated alias key in
|
||||
* standaloneRouting.cjs::AGENT_ROUTE_CONFIG (antigravity, claude-code, kiro).
|
||||
* Other agents fall through to the antigravity config and don't need their own
|
||||
* entry.
|
||||
*
|
||||
* Fix #8656: model mappings saved via the UI were invisible to the MITM proxy
|
||||
* because the proxy reads from key_value (namespace='mitmAlias'), not from
|
||||
* agent_bridge_mappings.
|
||||
*/
|
||||
export function syncAgentBridgeMappingsToMitmAlias(agentId: string): void {
|
||||
if (!MITM_ALIAS_AGENTS.has(agentId)) return;
|
||||
const rows = getMappingsForAgent(agentId);
|
||||
const mappings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
mappings[row.source_model] = row.target_model;
|
||||
}
|
||||
setMitmAliasAll(agentId, mappings);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
/** Run the capture-pipeline self-test (server/cert/dns reachability). */
|
||||
export function runDiagnose(): Promise<DiagnoseResult> {
|
||||
return requestJson<DiagnoseResult>("/api/tools/agent-bridge/diagnose");
|
||||
export function runDiagnose(agentId?: string): Promise<DiagnoseResult> {
|
||||
const url = agentId
|
||||
? `/api/tools/agent-bridge/diagnose?agentId=${encodeURIComponent(agentId)}`
|
||||
: "/api/tools/agent-bridge/diagnose";
|
||||
return requestJson<DiagnoseResult>(url);
|
||||
}
|
||||
|
||||
/** Untrust + remove the MITM root CA from the OS store (explicit, idempotent). */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { resolveMitmDataDir } from "./dataDir.ts";
|
||||
import { removeDNSEntry, removeDNSEntries, checkDNSEntryForAgent } from "./dns/dnsConfig.ts";
|
||||
import { removeDNSEntry, removeDNSEntries, checkDNSEntryForAgent, checkDNSEntry } from "./dns/dnsConfig.ts";
|
||||
import { provisionDnsEntries } from "./dns/provision.ts";
|
||||
import { generateCert } from "./cert/generate.ts";
|
||||
import { installCertResult, installCaCert } from "./cert/install.ts";
|
||||
@@ -395,15 +395,16 @@ export async function getMitmStatus(agentId?: string): Promise<{
|
||||
}
|
||||
|
||||
// Check DNS configuration. When an agentId is provided, check THAT agent's
|
||||
// own hosts (#8466) instead of always checking the Antigravity host set —
|
||||
// callers that don't pass agentId keep the legacy Antigravity-only check.
|
||||
// own hosts (#8466) instead of always checking the Antigravity host set.
|
||||
// Fix #8656: no-agentId path now uses checkDNSEntry() which is Windows-aware
|
||||
// (reads HOSTS_FILE = C:\Windows\System32\drivers\etc\hosts on Windows).
|
||||
let dnsConfigured = false;
|
||||
try {
|
||||
if (agentId) {
|
||||
dnsConfigured = checkDNSEntryForAgent(agentId);
|
||||
} else {
|
||||
const hostsContent = fs.readFileSync("/etc/hosts", "utf-8");
|
||||
dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent);
|
||||
// Use Windows-aware checkDNSEntry() instead of hardcoded /etc/hosts
|
||||
dnsConfigured = checkDNSEntry();
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
|
||||
@@ -341,12 +341,40 @@ function collectBodyRaw(req) {
|
||||
});
|
||||
}
|
||||
|
||||
function extractModel(body) {
|
||||
/**
|
||||
* Extract the source model name from request body or URL.
|
||||
*
|
||||
* For Antigravity (Gemini format):
|
||||
* - Body may have top-level `model` field: { model: "gemini-2.0-flash", request: {...} }
|
||||
* - URL may encode model: /v1beta/models/gemini-2.0-flash:generateContent
|
||||
*
|
||||
* For other agents (OpenAI format):
|
||||
* - Body has `model` field: { model: "gpt-4", messages: [...] }
|
||||
*
|
||||
* @param {Buffer} body - Request body buffer
|
||||
* @param {string} url - Request URL path
|
||||
* @returns {string|null} Extracted model name or null
|
||||
*/
|
||||
function extractModel(body, url) {
|
||||
// Try to extract from body first
|
||||
try {
|
||||
return JSON.parse(body.toString()).model || null;
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed && typeof parsed.model === "string" && parsed.model) {
|
||||
return parsed.model;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
// Invalid JSON or no model field
|
||||
}
|
||||
|
||||
// Try to extract from URL path (Gemini format: /v1beta/models/<model>:generateContent)
|
||||
if (url && typeof url === "string") {
|
||||
const match = url.match(/\/models\/([^/:]+)(?::|\/)/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,7 +632,7 @@ async function startMitmServer() {
|
||||
const host = String(req.headers.host || "")
|
||||
.split(":")[0]
|
||||
.toLowerCase();
|
||||
const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null;
|
||||
const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer, req.url) : null;
|
||||
|
||||
vlog(
|
||||
1,
|
||||
@@ -632,6 +660,30 @@ async function startMitmServer() {
|
||||
return passthrough(req, res, bodyBuffer);
|
||||
}
|
||||
|
||||
// FIX #8656: Capture ALL agent traffic (even passthrough) so Traffic Inspector
|
||||
// and model auto-detection work WITHOUT requiring mappings first.
|
||||
// This fixes the circular dependency: need mappings to see traffic, but need
|
||||
// to see traffic to create mappings.
|
||||
//
|
||||
// Capture happens BEFORE checking for mappings, so requests appear in Traffic
|
||||
// Inspector even when no mappings exist yet. Status is set to "in-flight"
|
||||
// initially; will be updated to the actual status code if intercepted.
|
||||
const startedAt = Date.now();
|
||||
captureToInspector({
|
||||
req,
|
||||
bodyBuffer,
|
||||
agentId,
|
||||
sourceModel: model,
|
||||
mappedModel: model, // Will be overridden if intercepted
|
||||
status: "in-flight", // Valid schema value (not "passthrough")
|
||||
respHeaders: {},
|
||||
respBody: null,
|
||||
respSize: 0,
|
||||
error: null,
|
||||
proxyLatencyMs: 0,
|
||||
upstreamLatencyMs: 0,
|
||||
});
|
||||
|
||||
const mappedOverride = getMappedOverride(model, agentId);
|
||||
|
||||
if (!mappedOverride) {
|
||||
|
||||
Reference in New Issue
Block a user