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:
Dulanjana Palamakumbura
2026-08-06 06:13:16 +05:30
committed by GitHub
parent ea4bbdf7c0
commit d91f7d1c3f
21 changed files with 1176 additions and 105 deletions

57
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Build App
on:
workflow_dispatch:
push:
branches: ["**"]
permissions:
contents: read
jobs:
build:
name: Fast Production Build
runs-on: ubuntu-latest
steps:
- name: Expand Virtual Memory (Native 10GB Swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Next.js app & CLI bundle
run: |
npm run build:release
env:
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
OMNIROUTE_USE_TURBOPACK: "1"
- name: Archive build outputs
run: |
tar -czf omniroute-build.tar.gz .build dist
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: omniroute-build
path: omniroute-build.tar.gz
retention-days: 7

View File

@@ -730,11 +730,22 @@ export function createSSEStream(options: StreamOptions = {}) {
? clientResponseFormat === FORMATS.CLAUDE
: sourceFormat === FORMATS.CLAUDE) === true;
// Antigravity/cloudcode streams terminate naturally on their last
// `data: {"response":{...}}` event, not on a `[DONE]` marker. Emitting
// `[DONE]` to the Antigravity IDE causes a protobuf parse failure
// (proto: syntax error (line 1:1): unexpected token [) because the
// Go binary's protobuf deserializer receives `[DONE]` as input.
const clientExpectsAntigravityStream =
(mode === STREAM_MODE.PASSTHROUGH
? clientResponseFormat === FORMATS.ANTIGRAVITY
: sourceFormat === FORMATS.ANTIGRAVITY) === true;
// Single source of truth for the [DONE] decision, used at both emission
// sites below. Only OpenAI Chat Completions clients expect [DONE];
// Responses API and Anthropic SSE terminate on their own protocol events
// (response.completed / message_stop respectively).
const shouldEmitDoneTerminator = !clientExpectsResponsesStream && !clientExpectsClaudeStream;
// Responses API, Anthropic SSE, and Antigravity/cloudcode terminate on
// their own protocol events (response.completed / message_stop / last
// response candidate respectively).
const shouldEmitDoneTerminator = !clientExpectsResponsesStream && !clientExpectsClaudeStream && !clientExpectsAntigravityStream;
let buffer = "";
let usage: UsageTokenRecord | null = null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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). */

View File

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

View File

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

View File

@@ -80,13 +80,24 @@ test("routeGuard: /api/tools/agent-bridge/ is SPAWN_CAPABLE", () => {
// ── GET /state ─────────────────────────────────────────────────────────────
test("GET /state: returns server + agents shape", async () => {
test("GET /state: returns both legacy (server/agents) and new (serverState/agentStates) keys (#8656)", async () => {
const res = await stateRoute.GET();
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.ok("server" in body, "body.server missing");
// Legacy keys (integration test + settings/mitm depend on these)
assert.ok("server" in body, "body.server missing — breaks backward compat");
assert.ok("agents" in body, "body.agents missing");
assert.ok(Array.isArray(body.agents), "agents should be array");
// New keys (#8656 fix — what UI actually reads)
assert.ok("serverState" in body, "body.serverState missing");
assert.ok("agentStates" in body, "body.agentStates missing");
assert.ok(Array.isArray(body.agentStates), "agentStates should be array");
assert.ok("bypassPatterns" in body, "body.bypassPatterns missing");
assert.ok(Array.isArray(body.bypassPatterns), "bypassPatterns should be array");
assert.ok("mappings" in body, "body.mappings missing");
assert.equal(typeof body.mappings, "object", "mappings should be object");
});
test("GET /state: error responses do not leak stack traces", async () => {

View File

@@ -0,0 +1,163 @@
/**
* Unit test: GET /api/tools/agent-bridge/agents/[id]/detected-models
* Verifies model auto-detection from intercepted traffic (#8656 follow-up D)
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { globalTrafficBuffer } from "../../src/mitm/inspector/buffer.ts";
import type { InterceptedRequest } from "../../src/mitm/inspector/types.ts";
test("GET /detected-models: returns unique source models from intercepted traffic", async () => {
// Mock some intercepted traffic with source models
const mockRequests: InterceptedRequest[] = [
{
id: "req1",
source: "agent-bridge",
agent: "cursor",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.cursor.sh",
path: "/v1/chat/completions",
requestHeaders: {},
requestBody: null,
requestSize: 100,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
sourceModel: "gpt-4-turbo",
mappedModel: "openai/gpt-4-turbo",
},
{
id: "req2",
source: "agent-bridge",
agent: "cursor",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.cursor.sh",
path: "/v1/chat/completions",
requestHeaders: {},
requestBody: null,
requestSize: 100,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
sourceModel: "claude-3-opus",
mappedModel: "anthropic/claude-3-opus-20240229",
},
{
id: "req3",
source: "agent-bridge",
agent: "cursor",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.cursor.sh",
path: "/v1/chat/completions",
requestHeaders: {},
requestBody: null,
requestSize: 100,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
sourceModel: "gpt-4-turbo", // Duplicate - should only appear once
mappedModel: "openai/gpt-4-turbo",
},
{
id: "req4",
source: "agent-bridge",
agent: "kiro",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.kiro.ai",
path: "/v1/chat/completions",
requestHeaders: {},
requestBody: null,
requestSize: 100,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
sourceModel: "gemini-pro",
mappedModel: "google/gemini-pro",
},
];
// Add mock requests to buffer
mockRequests.forEach((req) => globalTrafficBuffer.push(req));
try {
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts?t=" +
Date.now()
);
const res = await GET(
new Request("http://localhost/api/tools/agent-bridge/agents/cursor/detected-models"),
{ params: { id: "cursor" } }
);
assert.equal(res.status, 200, "Response should be 200 OK");
const body = (await res.json()) as {
agentId: string;
detectedModels: string[];
requestCount: number;
};
assert.equal(body.agentId, "cursor", "agentId should be cursor");
assert.ok(Array.isArray(body.detectedModels), "detectedModels should be array");
assert.equal(body.detectedModels.length, 2, "Should have 2 unique models (duplicates removed)");
assert.ok(
body.detectedModels.includes("gpt-4-turbo"),
"Should include gpt-4-turbo"
);
assert.ok(
body.detectedModels.includes("claude-3-opus"),
"Should include claude-3-opus"
);
assert.equal(body.requestCount, 3, "Should have 3 cursor requests");
} finally {
// Clean up buffer
globalTrafficBuffer.clear();
}
});
test("GET /detected-models: returns empty array for agent with no traffic", async () => {
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts?t=" +
Date.now()
);
const res = await GET(
new Request("http://localhost/api/tools/agent-bridge/agents/antigravity/detected-models"),
{ params: { id: "antigravity" } }
);
assert.equal(res.status, 200);
const body = (await res.json()) as {
agentId: string;
detectedModels: string[];
requestCount: number;
};
assert.equal(body.agentId, "antigravity");
assert.deepEqual(body.detectedModels, []);
assert.equal(body.requestCount, 0);
});
test("GET /detected-models: returns 404 for invalid agent id", async () => {
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts?t=" +
Date.now()
);
const res = await GET(
new Request("http://localhost/api/tools/agent-bridge/agents/invalid-agent/detected-models"),
{ params: { id: "invalid-agent" } }
);
assert.equal(res.status, 404);
});

View File

@@ -59,26 +59,29 @@ test("FALSE POSITIVE: only a leftover Antigravity host is present, Claude Code h
);
});
test("no-agentId call sites keep legacy Antigravity-only behavior unchanged", async () => {
test("no-agentId call sites: still Antigravity-only but Windows-aware (#8656)", async () => {
const realReadFileSync = fs.readFileSync.bind(fs);
mock.method(fs, "readFileSync", (p: string, enc?: BufferEncoding) => {
if (p === "/etc/hosts") {
// Claude Code host spoofed, but NO Antigravity host present. A caller
// that omits agentId (state/route.ts, server/route.ts, settings/mitm,
// cli-tools/antigravity-mitm) must still evaluate the legacy
// Antigravity-only regex, so this should remain false.
mock.method(fs, "readFileSync", (p: unknown, enc?: BufferEncoding) => {
// After #8656: no-agentId uses checkDNSEntry() which reads HOSTS_FILE
// (Windows-aware) instead of hardcoded /etc/hosts. Still Antigravity-only
// semantics (checks all 4 Antigravity hosts), but reads the correct file.
const pathStr = String(p);
const isHostsFile = pathStr === "/etc/hosts" || pathStr.includes("System32\\drivers\\etc\\hosts");
if (isHostsFile) {
// Claude Code host spoofed, but NO Antigravity host present. The legacy
// Antigravity-only check should still return false (unchanged semantics).
return "127.0.0.1 localhost\n127.0.0.1 api.anthropic.com\n::1 api.anthropic.com\n";
}
return realReadFileSync(p, enc);
return realReadFileSync(p as string, enc);
});
const { getMitmStatus } = await import("../../src/mitm/manager.ts?probe=8466-legacy");
const { getMitmStatus } = await import("../../src/mitm/manager.ts?probe=8466-legacy-8656");
const status = await getMitmStatus();
assert.equal(
status.dnsConfigured,
false,
"callers that omit agentId must keep the legacy Antigravity-only check"
"no-agentId still checks Antigravity-only (4 hosts via checkDNSEntry), now Windows-aware"
);
});

View File

@@ -0,0 +1,200 @@
/**
* Regression test for issue #8656 follow-up: model mappings saved via the UI
* are invisible to the MITM proxy because the proxy reads from key_value
* (namespace='mitmAlias') while the UI writes to agent_bridge_mappings.
*
* This test verifies that syncAgentBridgeMappingsToMitmAlias() properly copies
* mappings from agent_bridge_mappings to key_value for agents that have a
* registered alias key in standaloneRouting.cjs::AGENT_ROUTE_CONFIG.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "fs";
import os from "os";
import path from "path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8656-sync-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
// Import db core first to allow reset
const core = await import("../../src/lib/db/core.ts");
// Import getMitmAlias to verify key_value entries
const { getMitmAlias } = await import("../../src/lib/db/models/mitmAlias.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
/* noop */
}
});
// ── Sync Tests ─────────────────────────────────────────────────────────────
test("syncAgentBridgeMappingsToMitmAlias: copies antigravity mappings to key_value", async () => {
// Dynamic import after DB reset
const {
setMappings,
syncAgentBridgeMappingsToMitmAlias,
} = await import(
"../../src/lib/db/agentBridgeMappings.ts?t=" + Date.now()
);
// Arrange: save mappings for antigravity
setMappings("antigravity", [
{ source: "gpt-oss-120b-medium", target: "openai/gpt-4o" },
{ source: "gemini-2.0-flash", target: "anthropic/claude-sonnet-4" },
]);
// Act: sync to mitmAlias
syncAgentBridgeMappingsToMitmAlias("antigravity");
// Get a fresh import of getMitmAlias to read the key_value table
const { getMitmAlias: getAlias } = await import(
"../../src/lib/db/models/mitmAlias.ts?t=" + Date.now()
);
// Assert: key_value has the mappings
const alias = await getAlias("antigravity");
assert.ok(alias, "mitmAlias for antigravity should exist");
assert.equal(
alias["gpt-oss-120b-medium"],
"openai/gpt-4o",
"gpt-oss-120b-medium should map to openai/gpt-4o"
);
assert.equal(
alias["gemini-2.0-flash"],
"anthropic/claude-sonnet-4",
"gemini-2.0-flash should map to anthropic/claude-sonnet-4"
);
});
test("syncAgentBridgeMappingsToMitmAlias: skips agents not in MITM_ALIAS_AGENTS", async () => {
const {
setMappings,
syncAgentBridgeMappingsToMitmAlias,
} = await import(
"../../src/lib/db/agentBridgeMappings.ts?t=" + Date.now()
);
// Arrange: save mappings for cursor (not in MITM_ALIAS_AGENTS)
setMappings("cursor", [
{ source: "gpt-4o", target: "openai/gpt-4o" },
]);
// Act: sync should skip cursor (no-op)
syncAgentBridgeMappingsToMitmAlias("cursor");
// Assert: key_value should have no mitmAlias entry for cursor
const { getMitmAlias: getAlias } = await import(
"../../src/lib/db/models/mitmAlias.ts?t=" + Date.now()
);
const alias = await getAlias();
assert.equal(
alias["cursor"],
undefined,
"cursor should not have a mitmAlias entry"
);
});
test("syncAgentBridgeMappingsToMitmAlias: replaces existing mitmAlias entry", async () => {
const {
setMappings,
syncAgentBridgeMappingsToMitmAlias,
} = await import(
"../../src/lib/db/agentBridgeMappings.ts?t=" + Date.now()
);
// Arrange: save initial mappings and sync
setMappings("antigravity", [
{ source: "gpt-oss-120b-medium", target: "openai/gpt-4o" },
]);
syncAgentBridgeMappingsToMitmAlias("antigravity");
// Act: save different mappings and sync again
setMappings("antigravity", [
{ source: "gpt-oss-120b-medium", target: "anthropic/claude-opus-4" },
]);
syncAgentBridgeMappingsToMitmAlias("antigravity");
// Assert: key_value should have the updated mapping
const { getMitmAlias: getAlias } = await import(
"../../src/lib/db/models/mitmAlias.ts?t=" + Date.now()
);
const alias = await getAlias("antigravity");
assert.equal(
alias["gpt-oss-120b-medium"],
"anthropic/claude-opus-4",
"should have updated mapping after re-sync"
);
});
test("syncAgentBridgeMappingsToMitmAlias: empty mappings clear key_value entry", async () => {
const {
setMappings,
syncAgentBridgeMappingsToMitmAlias,
} = await import(
"../../src/lib/db/agentBridgeMappings.ts?t=" + Date.now()
);
// Arrange: save some mappings first
setMappings("antigravity", [
{ source: "gpt-oss-120b-medium", target: "openai/gpt-4o" },
]);
syncAgentBridgeMappingsToMitmAlias("antigravity");
// Act: clear mappings and sync
setMappings("antigravity", []);
syncAgentBridgeMappingsToMitmAlias("antigravity");
// Assert: key_value entry should be an empty object
const { getMitmAlias: getAlias } = await import(
"../../src/lib/db/models/mitmAlias.ts?t=" + Date.now()
);
const alias = await getAlias("antigravity");
assert.ok(alias, "antigravity mitmAlias entry should still exist (empty object)");
assert.equal(
Object.keys(alias).length,
0,
"antigravity mitmAlias should have no mappings"
);
});
test("syncAgentBridgeMappingsToMitmAlias: works for claude-code agent", async () => {
const {
setMappings,
syncAgentBridgeMappingsToMitmAlias,
} = await import(
"../../src/lib/db/agentBridgeMappings.ts?t=" + Date.now()
);
// Arrange: save and sync for claude-code
setMappings("claude-code", [
{ source: "claude-sonnet-4", target: "openai/gpt-4o" },
]);
syncAgentBridgeMappingsToMitmAlias("claude-code");
// Assert
const { getMitmAlias: getAlias } = await import(
"../../src/lib/db/models/mitmAlias.ts?t=" + Date.now()
);
const alias = await getAlias("claude-code");
assert.ok(alias, "mitmAlias for claude-code should exist");
assert.equal(
alias["claude-sonnet-4"],
"openai/gpt-4o",
"claude-sonnet-4 should map to openai/gpt-4o"
);
});

View File

@@ -0,0 +1,208 @@
/**
* Regression test for issue #8656: Agent Bridge DNS start succeeds but UI does
* not show model mapping or dns-configured status.
*
* Root cause: GET /api/tools/agent-bridge/state returns { server, agents } but
* the UI expects { serverState, agentStates, bypassPatterns, mappings }. The
* normalizeAgentBridgeState function intentionally does NOT coerce the `agents`
* key to `agentStates` (comment at normalizeState.ts:45-47), so after every
* refresh agentStates=[], mappings={}, bypassPatterns=[].
*
* DNS toggle DOES write dns_enabled=true to the DB via upsertAgentBridgeState
* in agents/[id]/dns/route.ts:72, but the state route never reads
* getAllAgentBridgeStates(), so the UI never sees the flag flip.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "fs";
import os from "os";
import path from "path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8656-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
// Import db core first to allow reset
const core = await import("../../src/lib/db/core.ts");
const { upsertAgentBridgeState } = await import("../../src/lib/db/agentBridgeState.ts");
const { setMappings } = await import("../../src/lib/db/agentBridgeMappings.ts");
const { replaceUserBypassPatterns } = await import("../../src/lib/db/agentBridgeBypass.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
/* noop */
}
});
// ── Core #8656 Regression Tests ────────────────────────────────────────────
test("GET /state: returns agentStates array with dns_enabled from DB (#8656)", async () => {
// Arrange: simulate the user clicking "Start DNS" for claude-code, which
// writes dns_enabled=true to the DB via agents/[id]/dns/route.ts:72
upsertAgentBridgeState({ agent_id: "claude-code", dns_enabled: true });
// Dynamic import to bypass module cache after DB reset
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
// Act: the UI polls /state after the DNS toggle
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
// Assert: agentStates must be populated (not empty) so the UI can read dns_enabled
assert.ok(Array.isArray(body.agentStates), "body.agentStates missing or not array");
assert.ok(
body.agentStates.length > 0,
"agentStates should not be empty — at least claude-code should be present"
);
const claudeCodeState = body.agentStates.find(
(s: { agent_id: string }) => s.agent_id === "claude-code"
);
assert.ok(claudeCodeState, "claude-code not in agentStates");
assert.equal(
(claudeCodeState as { dns_enabled: boolean }).dns_enabled,
true,
"dns_enabled should be true after upsertAgentBridgeState"
);
});
test("GET /state: returns mappings object keyed by agentId (#8656)", async () => {
// Arrange: simulate the setup wizard configuring model mappings for claude-code
setMappings("claude-code", [{ source: "claude-sonnet-4", target: "openai/gpt-4o" }]);
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
// Act
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
// Assert: mappings must be present so the UI can render the model mapping table
assert.ok(typeof body.mappings === "object", "body.mappings missing");
assert.ok(Array.isArray(body.mappings["claude-code"]), "mappings[claude-code] missing");
assert.equal(body.mappings["claude-code"].length, 1);
assert.equal(body.mappings["claude-code"][0].source, "claude-sonnet-4");
assert.equal(body.mappings["claude-code"][0].target, "openai/gpt-4o");
});
test("GET /state: returns bypassPatterns array (#8656)", async () => {
// Arrange: simulate the user configuring custom bypass patterns
replaceUserBypassPatterns(["*.internal", "localhost"]);
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
// Act
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
// Assert: bypassPatterns must be present so the UI can display/edit them
assert.ok(Array.isArray(body.bypassPatterns), "body.bypassPatterns missing");
assert.ok(body.bypassPatterns.length >= 2, "should include user patterns");
assert.ok(body.bypassPatterns.includes("*.internal"));
assert.ok(body.bypassPatterns.includes("localhost"));
});
test("GET /state: serverState.certTrusted distinct from certExists (#8656)", async () => {
// certTrusted (OS trust store check) was confused with certExists (file on disk).
// getMitmStatus returns certExists only; normalizeState maps certExists → certTrusted
// as a fallback, so the UI showed "trusted" when the cert file existed but wasn't
// actually trusted by the OS.
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
// Assert: certExists and certTrusted should be distinct fields
const server = body.server as Record<string, unknown>;
assert.ok("certExists" in server, "server.certExists missing");
assert.ok("certTrusted" in server, "server.certTrusted missing");
// Both should be false when no cert exists (this test doesn't generate a cert)
assert.equal(server.certExists, false, "certExists should be false (no cert generated)");
assert.equal(
server.certTrusted,
false,
"certTrusted should be false (no cert in OS trust store)"
);
});
test("GET /state: maintains backward compat (server + agents keys) (#8656)", async () => {
// Integration tests and other routes (settings/mitm) depend on the legacy
// { server, agents } shape. The fix must add the new keys without breaking old callers.
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
// Assert: legacy keys still present
assert.ok("server" in body, "body.server missing — breaks backward compat");
assert.ok("agents" in body, "body.agents missing");
assert.ok(Array.isArray(body.agents), "agents should be array");
// Assert: new keys also present
assert.ok("serverState" in body, "body.serverState missing");
assert.ok("agentStates" in body, "body.agentStates missing");
assert.ok("bypassPatterns" in body, "body.bypassPatterns missing");
assert.ok("mappings" in body, "body.mappings missing");
});
test("GET /state: agentStates entries have expected shape (#8656)", async () => {
// Arrange: set all fields for antigravity to ensure they're mapped through
upsertAgentBridgeState({
agent_id: "antigravity",
dns_enabled: true,
cert_trusted: false,
setup_completed: true,
last_started_at: "2026-07-27T12:00:00.000Z",
last_error: null,
});
const { GET } = await import(
"../../src/app/api/tools/agent-bridge/state/route.ts?t=" + Date.now()
);
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
const antigravityState = body.agentStates.find(
(s: { agent_id: string }) => s.agent_id === "antigravity"
);
assert.ok(antigravityState, "antigravity should be in agentStates");
const state = antigravityState as {
agent_id: string;
dns_enabled: boolean;
cert_trusted: boolean;
setup_completed: boolean;
last_started_at: string | null;
last_error: string | null;
};
assert.equal(state.agent_id, "antigravity");
assert.equal(state.dns_enabled, true);
assert.equal(state.cert_trusted, false);
assert.equal(state.setup_completed, true);
assert.equal(state.last_started_at, "2026-07-27T12:00:00.000Z");
assert.equal(state.last_error, null);
});