diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..954ac64653 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 3958745e02..757ab15b3c 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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; diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx index aae3f01cab..8a5fdb1a62 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx @@ -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} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx index 0d1bf052aa..4e54226fc7 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx @@ -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; onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise; @@ -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} /> )} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx index d2fca8c359..fa4365b385 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx @@ -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; onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise; @@ -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} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx index 7d2feaef94..ac67e9a7b2 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx @@ -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 ( -

- {t("noMappings") || "No model mappings configured. Run setup wizard to auto-detect models."} -

- ); - } - return (
-
- - - - - - - - - {rows.map((row, i) => ( - - - - - ))} - -
- {t("sourceModel") || "Source model (agent native)"} - - {t("targetModel") || "Target model (OmniRoute)"} -
- {row.source} - - -
-
+ {rows.length === 0 ? ( +
+

+ {t("noMappingsDesc") || "No model mappings configured yet. Add mappings to route agent requests through OmniRoute."} +

+ +
+ ) : ( + <> +
+ + + + + + + + + + {rows.map((row, i) => ( + + + + + + ))} + +
+ {t("sourceModel") || "Source model (agent native)"} + + {t("targetModel") || "Target model (OmniRoute)"} +
+ 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" + /> + + + + +
+
-
- -
+
+ + +
+ + )} {selectorOpen !== null && ( void; onDnsToggle: (agentId: string, enabled: boolean) => Promise; + onMappingsSave: (agentId: string, mappings: { source: string; target: string }[]) => Promise; } 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("verify"); const [enablingDns, setEnablingDns] = useState(false); + const [detectedModels, setDetectedModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + const [selectedModels, setSelectedModels] = useState>(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({ check_circle

{t("wizardStep3Success")}

-

{t("wizardStep3Desc")}

+ + {loadingModels ? ( +
+ progress_activity + Detecting models from intercepted traffic... +
+ ) : detectedModels.length > 0 ? ( +
+

+ Found {detectedModels.length} model{detectedModels.length !== 1 ? "s" : ""} in intercepted traffic. Select the ones you want to add: +

+
+ {detectedModels.map((model) => ( + + ))} +
+ {selectedModels.size > 0 && ( +

+ {selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected. You'll map them to OmniRoute models in the next screen. +

+ )} +
+ ) : ( +
+

+ No models detected yet. Use {target.name} to make a request, then run this wizard again to auto-detect models from traffic. +

+

+ Or close this wizard and add mappings manually in the agent card. +

+
+ )} )} @@ -247,13 +361,25 @@ export function SetupWizard({ )} {step === "mappings" && ( - + <> + {detectedModels.length > 0 && selectedModels.size > 0 ? ( + + ) : ( + + )} + )} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts new file mode 100644 index 0000000000..27edab6efd --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts @@ -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([ + "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 { + 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(); + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts index 4ca2241681..4e03b0a1c6 100644 --- a/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts +++ b/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts @@ -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 { 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 { @@ -48,12 +53,23 @@ export async function GET(request: Request): Promise { 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 }); diff --git a/src/app/api/tools/agent-bridge/state/route.ts b/src/app/api/tools/agent-bridge/state/route.ts index f685332de4..b2c0cc2dca 100644 --- a/src/app/api/tools/agent-bridge/state/route.ts +++ b/src/app/api/tools/agent-bridge/state/route.ts @@ -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 { 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)); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a17e12b9d1..ae7e5099f5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/lib/db/agentBridgeMappings.ts b/src/lib/db/agentBridgeMappings.ts index 200cb2412e..beaca51521 100644 --- a/src/lib/db/agentBridgeMappings.ts +++ b/src/lib/db/agentBridgeMappings.ts @@ -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 = {}; + for (const row of rows) { + mappings[row.source_model] = row.target_model; + } + setMitmAliasAll(agentId, mappings); +} diff --git a/src/lib/inspector/agentBridgeMaintenanceApi.ts b/src/lib/inspector/agentBridgeMaintenanceApi.ts index 8ad7bdf35b..be2385e735 100644 --- a/src/lib/inspector/agentBridgeMaintenanceApi.ts +++ b/src/lib/inspector/agentBridgeMaintenanceApi.ts @@ -33,8 +33,11 @@ async function requestJson(url: string, init?: RequestInit): Promise { } /** Run the capture-pipeline self-test (server/cert/dns reachability). */ -export function runDiagnose(): Promise { - return requestJson("/api/tools/agent-bridge/diagnose"); +export function runDiagnose(agentId?: string): Promise { + const url = agentId + ? `/api/tools/agent-bridge/diagnose?agentId=${encodeURIComponent(agentId)}` + : "/api/tools/agent-bridge/diagnose"; + return requestJson(url); } /** Untrust + remove the MITM root CA from the OS store (explicit, idempotent). */ diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 74e0bd81d1..027d5ac880 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -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 diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index 443788d7d2..cedf914efd 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -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/: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) { diff --git a/tests/integration/agent-bridge-routes.test.ts b/tests/integration/agent-bridge-routes.test.ts index 4724043fc3..71b619595c 100644 --- a/tests/integration/agent-bridge-routes.test.ts +++ b/tests/integration/agent-bridge-routes.test.ts @@ -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; - 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 () => { diff --git a/tests/unit/agent-bridge-detected-models-8656.test.ts b/tests/unit/agent-bridge-detected-models-8656.test.ts new file mode 100644 index 0000000000..2d1215e5df --- /dev/null +++ b/tests/unit/agent-bridge-detected-models-8656.test.ts @@ -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); +}); diff --git a/tests/unit/agent-bridge-dns-per-agent-8466.test.ts b/tests/unit/agent-bridge-dns-per-agent-8466.test.ts index c9ce228bab..6bc6cffb5f 100644 --- a/tests/unit/agent-bridge-dns-per-agent-8466.test.ts +++ b/tests/unit/agent-bridge-dns-per-agent-8466.test.ts @@ -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" ); }); diff --git a/tests/unit/agent-bridge-mappings-sync-8656.test.ts b/tests/unit/agent-bridge-mappings-sync-8656.test.ts new file mode 100644 index 0000000000..1c7081cb4d --- /dev/null +++ b/tests/unit/agent-bridge-mappings-sync-8656.test.ts @@ -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" + ); +}); diff --git a/tests/unit/agent-bridge-state-full-payload-8656.test.ts b/tests/unit/agent-bridge-state-full-payload-8656.test.ts new file mode 100644 index 0000000000..c53bc040c0 --- /dev/null +++ b/tests/unit/agent-bridge-state-full-payload-8656.test.ts @@ -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; + + // 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; + + // 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; + + // 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; + + // Assert: certExists and certTrusted should be distinct fields + const server = body.server as Record; + 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; + + // 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; + + 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); +});