diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx new file mode 100644 index 0000000000..91184a9b8e --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { RiskNoticeBanner } from "./components/RiskNoticeBanner"; +import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard"; +import { AgentList } from "./components/AgentList"; +import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders"; +import { useAgentBridgeState } from "./hooks/useAgentBridgeState"; +import type { MitmTarget } from "@/mitm/types"; +import type { MappingRow } from "./components/ModelMappingTable"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AgentStateEntry { + agent_id: string; + dns_enabled: boolean; + cert_trusted: boolean; + setup_completed: boolean; + last_started_at: string | null; + last_error: string | null; +} + +export interface AgentBridgeServerState { + running: boolean; + port: number; + certTrusted: boolean; + upstreamCa: string | null; + lastStartedAt: string | null; + activeConns: number; + interceptedCount: number; +} + +export type AgentMappingsMap = Record; + +export interface AgentBridgePageData { + serverState: AgentBridgeServerState; + agentStates: AgentStateEntry[]; + bypassPatterns: string[]; + mappings: AgentMappingsMap; +} + +interface AgentBridgePageClientProps { + initialData: AgentBridgePageData; + targets: MitmTarget[]; + hasProviders: boolean; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export default function AgentBridgePageClient({ + initialData, + targets, + hasProviders, +}: AgentBridgePageClientProps) { + const t = useTranslations("agentBridge"); + const { data, refresh } = useAgentBridgeState({ initialData }); + const [actionError, setActionError] = useState(null); + + // ── Server actions ──────────────────────────────────────────────────────── + + const handleServerAction = useCallback( + async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => { + setActionError(null); + try { + const res = await fetch("/api/tools/agent-bridge/server", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }))) as { + error?: { message?: string }; + }; + throw new Error(err.error?.message ?? `HTTP ${res.status}`); + } + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Unknown error"); + } + }, + [refresh] + ); + + // ── Upstream CA ─────────────────────────────────────────────────────────── + + const handleUpstreamCaSave = useCallback(async (path: string) => { + setActionError(null); + try { + const res = await fetch("/api/tools/agent-bridge/upstream-ca", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Unknown error"); + } + }, [refresh]); + + // ── Bypass list ─────────────────────────────────────────────────────────── + + const handleBypassSave = useCallback(async (patterns: string[]) => { + setActionError(null); + try { + const res = await fetch("/api/tools/agent-bridge/bypass", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patterns }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Unknown error"); + } + }, [refresh]); + + // ── DNS toggle ──────────────────────────────────────────────────────────── + + const handleDnsToggle = useCallback( + async (agentId: string, enabled: boolean) => { + setActionError(null); + try { + const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Unknown error"); + } + }, + [refresh] + ); + + // ── Mappings save ───────────────────────────────────────────────────────── + + const handleMappingsSave = useCallback( + async (agentId: string, mappings: MappingRow[]) => { + setActionError(null); + try { + const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/mappings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mappings }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Unknown error"); + } + }, + [refresh] + ); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Risk banner */} + + + {/* Error alert */} + {actionError && ( +
+ error + {actionError} + +
+ )} + + {/* Empty state: no providers */} + {!hasProviders ? ( + + ) : ( + <> + {/* Server card */} + + + {/* Agent list */} + + + {/* Quick links */} +
+

+ {t("quickLinks") || "Quick links"} +

+
+ + dns + {t("quickLinkProviders") || "Configure providers"} + + + network_check + {t("quickLinkInspector") || "View traffic in Traffic Inspector"} + +
+
+ + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeServerCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeServerCard.tsx new file mode 100644 index 0000000000..be1c75a7c3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeServerCard.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { CertStatusIcon } from "./shared/CertStatusIcon"; +import { UpstreamCaField } from "./UpstreamCaField"; +import { BypassListEditor } from "./BypassListEditor"; +import type { AgentBridgeServerState } from "../AgentBridgePageClient"; + +interface AgentBridgeServerCardProps { + serverState: AgentBridgeServerState; + onAction: (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => Promise; + onUpstreamCaSave: (path: string) => Promise; + onBypassSave: (patterns: string[]) => Promise; + bypassPatterns: string[]; +} + +/** + * Global server card — status + action buttons + CA field + bypass list. + * Matches plan 11 §3 AgentBridge Server layout. + */ +export function AgentBridgeServerCard({ + serverState, + onAction, + onUpstreamCaSave, + onBypassSave, + bypassPatterns, +}: AgentBridgeServerCardProps) { + const t = useTranslations("agentBridge"); + const [loading, setLoading] = useState(null); + const [expanded, setExpanded] = useState(false); + const [upstreamCa, setUpstreamCa] = useState(serverState.upstreamCa ?? ""); + + const runAction = async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => { + setLoading(action); + try { + await onAction(action); + } finally { + setLoading(null); + } + }; + + const isRunning = serverState.running; + + return ( +
+ {/* Header row */} +
+
+
+ link +
+
+

+ {t("serverCardTitle") || "AgentBridge Server"} + + + {isRunning ? t("statusRunning") || "Running" : t("statusStopped") || "Stopped"} + +

+
+ + {t("serverPort") || "Port"}: {serverState.port ?? 443} + + + {serverState.activeConns !== undefined && ( + + {t("serverConns") || "Connections"}: {serverState.activeConns} + + )} + {serverState.interceptedCount !== undefined && ( + + {t("serverIntercepted") || "Intercepted"}: {serverState.interceptedCount.toLocaleString()} + + )} + {serverState.lastStartedAt && ( + + {t("serverLastStarted") || "Last started"}:{" "} + {new Date(serverState.lastStartedAt).toLocaleTimeString()} + + )} +
+
+
+ + +
+ + {/* Action buttons */} +
+ + + + + + + + + + download + {t("downloadCert") || "Download Cert"} + + + +
+ + {/* Expanded: CA + Bypass */} + {expanded && ( +
+ +
+

+ {t("bypassSectionTitle") || "Bypass List"} +

+

+ {t("bypassSectionDesc") || + "Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks, .gov, and corporate SSO."} +

+ +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx new file mode 100644 index 0000000000..d924ba1b2b --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { AgentIcon } from "./shared/AgentIcon"; +import { DnsStatusBadge } from "./shared/DnsStatusBadge"; +import { ModelMappingTable } from "./ModelMappingTable"; +import { SetupWizard } from "./SetupWizard"; +import type { MitmTarget } from "@/mitm/types"; +import type { AgentStateEntry } from "../AgentBridgePageClient"; +import type { MappingRow } from "./ModelMappingTable"; + +interface AgentCardProps { + target: MitmTarget; + agentState: AgentStateEntry | undefined; + serverRunning: boolean; + mappings: MappingRow[]; + onDnsToggle: (agentId: string, enabled: boolean) => Promise; + onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise; +} + +/** + * Expandable card for a single IDE agent. + */ +export function AgentCard({ + target, + agentState, + serverRunning, + mappings, + onDnsToggle, + onMappingsSave, +}: AgentCardProps) { + const t = useTranslations("agentBridge"); + const [expanded, setExpanded] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); + const [dnsLoading, setDnsLoading] = useState(false); + + const dnsEnabled = agentState?.dns_enabled ?? false; + const setupCompleted = agentState?.setup_completed ?? false; + const certTrusted = agentState?.cert_trusted ?? false; + const isInvestigating = target.viability === "investigating"; + + const getStatusBadge = () => { + if (isInvestigating) { + return ( + + search + {t("statusInvestigating") || "Investigating"} + + ); + } + if (setupCompleted && dnsEnabled) { + return ( + + + {t("statusActive") || "Active"} + + ); + } + if (!setupCompleted) { + return ( + + settings + {t("statusSetupRequired") || "Setup required"} + + ); + } + return ( + + warning + {t("statusDnsOff") || "DNS off"} + + ); + }; + + const handleDnsToggle = async () => { + setDnsLoading(true); + try { + await onDnsToggle(target.id, !dnsEnabled); + } finally { + setDnsLoading(false); + } + }; + + return ( + <> +
+ {/* Card header */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* Hosts */} +
+

+ {t("agentHosts") || "Intercepted hosts"} +

+
+ {target.hosts.map((h) => ( + + {h} + + ))} +
+
+ + {/* Cert status */} +
+ + {certTrusted ? "verified_user" : "lock_open"} + + {certTrusted + ? t("certTrusted") || "Certificate trusted" + : t("certNotTrusted") || "Certificate not trusted"} +
+ + {/* Investigating notice */} + {isInvestigating && ( +
+

+ {t("investigatingNotice") || + "This agent is under investigation. Hosts and API surface are still being confirmed. Setup will be available once the upstream API is documented."} +

+
+ )} + + {/* Model mappings */} + {!isInvestigating && ( +
+

+ {t("modelMappingsLabel") || "Model mappings"} +

+ +
+ )} + + {/* Action buttons */} +
+ {!isInvestigating && ( + + )} + + {!isInvestigating && ( + + )} + + + network_check + {t("viewTraffic") || "View traffic"} + +
+
+ )} +
+ + {wizardOpen && ( + setWizardOpen(false)} + onDnsToggle={onDnsToggle} + /> + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx new file mode 100644 index 0000000000..d2f97e9bf0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { AgentCard } from "./AgentCard"; +import type { MitmTarget } from "@/mitm/types"; +import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient"; +import type { MappingRow } from "./ModelMappingTable"; + +interface AgentListProps { + targets: MitmTarget[]; + agentStates: AgentStateEntry[]; + serverRunning: boolean; + mappingsMap: AgentMappingsMap; + onDnsToggle: (agentId: string, enabled: boolean) => Promise; + onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise; +} + +type SetupFilter = "all" | "active" | "setup-required" | "investigating"; + +/** + * Grid of agent cards with filter + search controls. + * Matches plan 11 §3 IDE Agents section. + */ +export function AgentList({ + targets, + agentStates, + serverRunning, + mappingsMap, + onDnsToggle, + onMappingsSave, +}: AgentListProps) { + const t = useTranslations("agentBridge"); + const [filter, setFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const stateByAgent = Object.fromEntries(agentStates.map((s) => [s.agent_id, s])); + + const filtered = targets.filter((target) => { + // Search filter + if (search) { + const q = search.toLowerCase(); + if ( + !target.name.toLowerCase().includes(q) && + !target.id.toLowerCase().includes(q) && + !target.hosts.some((h) => h.toLowerCase().includes(q)) + ) { + return false; + } + } + + const state = stateByAgent[target.id]; + + // Setup status filter + if (filter === "active") { + return state?.dns_enabled && state?.setup_completed; + } + if (filter === "setup-required") { + return !state?.setup_completed && target.viability !== "investigating"; + } + if (filter === "investigating") { + return target.viability === "investigating"; + } + + return true; + }); + + const filterOptions: { id: SetupFilter; label: string }[] = [ + { id: "all", label: t("filterAll") || "All" }, + { id: "active", label: t("filterActive") || "Active" }, + { id: "setup-required", label: t("filterSetupRequired") || "Setup required" }, + { id: "investigating", label: t("filterInvestigating") || "Investigating" }, + ]; + + return ( +
+ {/* Controls */} +
+

+ {t("agentListTitle") || "IDE Agents"}{" "} + ({targets.length}) +

+ + {/* Filter buttons */} +
+ {filterOptions.map((opt) => ( + + ))} +
+ + {/* Search */} +
+ + search + + setSearch(e.target.value)} + /> +
+
+ + {/* Grid */} +
+ {filtered.length === 0 ? ( +
+ + search_off + +

{t("noAgentsMatch") || "No agents match the current filter"}

+
+ ) : ( + filtered.map((target) => ( + + )) + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor.tsx new file mode 100644 index 0000000000..61db7b78fa --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +const DEFAULT_BYPASS_PATTERNS = [ + "*.bank.*", + "*.gov.*", + "*.okta.com", + "*.auth0.com", +]; + +interface BypassListEditorProps { + patterns: string[]; + onSave: (patterns: string[]) => Promise; +} + +/** + * Textarea / chip editor for user-defined bypass patterns. + * Shows read-only defaults + editable user list. + */ +export function BypassListEditor({ patterns, onSave }: BypassListEditorProps) { + const t = useTranslations("agentBridge"); + const [userInput, setUserInput] = useState(patterns.join("\n")); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + const parsed = userInput + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + await onSave(parsed); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

+ {t("bypassDefaultsLabel") || "Default bypass patterns (read-only)"} +

+
+ {DEFAULT_BYPASS_PATTERNS.map((p) => ( + + {p} + + ))} +
+
+ +
+ +