merge(F7): AgentBridge UI into Group A parent

This commit is contained in:
diegosouzapw
2026-05-28 07:29:56 -03:00
27 changed files with 2833 additions and 26 deletions

File diff suppressed because one or more lines are too long

View File

@@ -32,28 +32,28 @@ import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs"
import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"
import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"
import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs"
import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs"
import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs"
import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs"
import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs"
import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs"
import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs"
import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs"
import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"
import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs"
import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"
import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs"
import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs"
import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs"
import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs"
import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs"
import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs"
import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs"
import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs"
import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs"
import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs"
import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs"
import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs"
import * as __fd_glob_31 from "../docs/guides/USER_GUIDE.md?collection=docs"
import * as __fd_glob_30 from "../docs/guides/UNINSTALL.md?collection=docs"
import * as __fd_glob_29 from "../docs/guides/TROUBLESHOOTING.md?collection=docs"
import * as __fd_glob_28 from "../docs/guides/TERMUX_GUIDE.md?collection=docs"
import * as __fd_glob_27 from "../docs/guides/SETUP_GUIDE.md?collection=docs"
import * as __fd_glob_26 from "../docs/guides/PWA_GUIDE.md?collection=docs"
import * as __fd_glob_25 from "../docs/guides/KIRO_SETUP.md?collection=docs"
import * as __fd_glob_24 from "../docs/guides/I18N.md?collection=docs"
import * as __fd_glob_23 from "../docs/guides/FEATURES.md?collection=docs"
import * as __fd_glob_22 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs"
import * as __fd_glob_21 from "../docs/guides/DOCKER_GUIDE.md?collection=docs"
import * as __fd_glob_20 from "../docs/frameworks/WEBHOOKS.md?collection=docs"
import * as __fd_glob_19 from "../docs/frameworks/SKILLS.md?collection=docs"
import * as __fd_glob_18 from "../docs/frameworks/OPENCODE.md?collection=docs"
import * as __fd_glob_17 from "../docs/frameworks/MEMORY.md?collection=docs"
import * as __fd_glob_16 from "../docs/frameworks/MCP-SERVER.md?collection=docs"
import * as __fd_glob_15 from "../docs/frameworks/GAMIFICATION.md?collection=docs"
import * as __fd_glob_14 from "../docs/frameworks/EVALS.md?collection=docs"
import * as __fd_glob_13 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"
import * as __fd_glob_12 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs"
import * as __fd_glob_11 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"
import * as __fd_glob_10 from "../docs/frameworks/A2A-SERVER.md?collection=docs"
import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs"
import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"
import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"
@@ -72,4 +72,4 @@ const create = server<typeof Config, import("fumadocs-mdx/runtime/types").Intern
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_55, "architecture/meta.json": __fd_glob_56, "compression/meta.json": __fd_glob_57, "frameworks/meta.json": __fd_glob_58, "guides/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "reference/meta.json": __fd_glob_61, "reference/openapi.yaml": __fd_glob_62, "routing/meta.json": __fd_glob_63, "security/meta.json": __fd_glob_64, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/STEALTH_GUIDE.md": __fd_glob_54, });
export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_55, "architecture/meta.json": __fd_glob_56, "compression/meta.json": __fd_glob_57, "frameworks/meta.json": __fd_glob_58, "guides/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "reference/meta.json": __fd_glob_61, "reference/openapi.yaml": __fd_glob_62, "routing/meta.json": __fd_glob_63, "security/meta.json": __fd_glob_64, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "frameworks/A2A-SERVER.md": __fd_glob_10, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_11, "frameworks/CLOUD_AGENT.md": __fd_glob_12, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_13, "frameworks/EVALS.md": __fd_glob_14, "frameworks/GAMIFICATION.md": __fd_glob_15, "frameworks/MCP-SERVER.md": __fd_glob_16, "frameworks/MEMORY.md": __fd_glob_17, "frameworks/OPENCODE.md": __fd_glob_18, "frameworks/SKILLS.md": __fd_glob_19, "frameworks/WEBHOOKS.md": __fd_glob_20, "guides/DOCKER_GUIDE.md": __fd_glob_21, "guides/ELECTRON_GUIDE.md": __fd_glob_22, "guides/FEATURES.md": __fd_glob_23, "guides/I18N.md": __fd_glob_24, "guides/KIRO_SETUP.md": __fd_glob_25, "guides/PWA_GUIDE.md": __fd_glob_26, "guides/SETUP_GUIDE.md": __fd_glob_27, "guides/TERMUX_GUIDE.md": __fd_glob_28, "guides/TROUBLESHOOTING.md": __fd_glob_29, "guides/UNINSTALL.md": __fd_glob_30, "guides/USER_GUIDE.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/STEALTH_GUIDE.md": __fd_glob_54, });

View File

@@ -1,6 +1,9 @@
import { redirect } from "next/navigation";
/**
* MITM Proxy page — moved to AgentBridge (plan 11).
* Redirects to the new location at /dashboard/tools/agent-bridge.
*/
export default function MitmProxyPage() {
// MITM Proxy será movido para Tools/AgentBridge (plano 11)
redirect("/dashboard/system/proxy");
redirect("/dashboard/tools/agent-bridge");
}

View File

@@ -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<string, MappingRow[]>;
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<string | null>(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 (
<div className="flex flex-col gap-5">
{/* Risk banner */}
<RiskNoticeBanner />
{/* Error alert */}
{actionError && (
<div
role="alert"
className="flex items-center gap-2 rounded-xl border border-red-500/30 bg-red-500/5 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
<span className="material-symbols-outlined text-[16px]">error</span>
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="ml-auto text-red-500 hover:text-red-400"
aria-label="Dismiss"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
{/* Empty state: no providers */}
{!hasProviders ? (
<EmptyStateNoProviders />
) : (
<>
{/* Server card */}
<AgentBridgeServerCard
serverState={data.serverState}
onAction={handleServerAction}
onUpstreamCaSave={handleUpstreamCaSave}
onBypassSave={handleBypassSave}
bypassPatterns={data.bypassPatterns}
/>
{/* Agent list */}
<AgentList
targets={targets}
agentStates={data.agentStates}
serverRunning={data.serverState.running}
mappingsMap={data.mappings}
onDnsToggle={handleDnsToggle}
onMappingsSave={handleMappingsSave}
/>
{/* Quick links */}
<div className="rounded-xl border border-border/40 bg-card px-5 py-4">
<h3 className="text-xs font-semibold text-text-muted mb-2 uppercase tracking-wide">
{t("quickLinks") || "Quick links"}
</h3>
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[14px]">dns</span>
{t("quickLinkProviders") || "Configure providers"}
</Link>
<Link
href="/dashboard/tools/traffic-inspector"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[14px]">network_check</span>
{t("quickLinkInspector") || "View traffic in Traffic Inspector"}
</Link>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -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<void>;
onUpstreamCaSave: (path: string) => Promise<void>;
onBypassSave: (patterns: string[]) => Promise<void>;
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<string | null>(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 (
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
{/* Header row */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-[20px] text-primary">link</span>
</div>
<div>
<h2 className="text-sm font-semibold text-text-main flex items-center gap-2">
{t("serverCardTitle") || "AgentBridge Server"}
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
isRunning
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-zinc-400"}`}
/>
{isRunning ? t("statusRunning") || "Running" : t("statusStopped") || "Stopped"}
</span>
</h2>
<div className="flex items-center gap-3 mt-0.5 text-xs text-text-muted">
<span>
{t("serverPort") || "Port"}: {serverState.port ?? 443}
</span>
<CertStatusIcon trusted={serverState.certTrusted ?? false} />
{serverState.activeConns !== undefined && (
<span>
{t("serverConns") || "Connections"}: {serverState.activeConns}
</span>
)}
{serverState.interceptedCount !== undefined && (
<span>
{t("serverIntercepted") || "Intercepted"}: {serverState.interceptedCount.toLocaleString()}
</span>
)}
{serverState.lastStartedAt && (
<span>
{t("serverLastStarted") || "Last started"}:{" "}
{new Date(serverState.lastStartedAt).toLocaleTimeString()}
</span>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-text-muted hover:text-text-main transition-colors"
aria-label={expanded ? "Collapse" : "Expand"}
>
<span className="material-symbols-outlined text-[18px]">
{expanded ? "expand_less" : "expand_more"}
</span>
</button>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2 px-5 pb-4">
<button
type="button"
onClick={() => runAction("start")}
disabled={isRunning || loading !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-500/10 text-emerald-600 px-3 py-1.5 text-xs font-medium hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{loading === "start" ? t("starting") || "Starting…" : t("startServer") || "Start"}
</button>
<button
type="button"
onClick={() => runAction("stop")}
disabled={!isRunning || loading !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/10 text-red-600 px-3 py-1.5 text-xs font-medium hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">stop</span>
{loading === "stop" ? t("stopping") || "Stopping…" : t("stopServer") || "Stop"}
</button>
<button
type="button"
onClick={() => runAction("restart")}
disabled={loading !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/10 text-amber-600 px-3 py-1.5 text-xs font-medium hover:bg-amber-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
{loading === "restart" ? t("restarting") || "Restarting…" : t("restartServer") || "Restart"}
</button>
<button
type="button"
onClick={() => runAction("trust-cert")}
disabled={loading !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500/10 text-blue-600 px-3 py-1.5 text-xs font-medium hover:bg-blue-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">security</span>
{loading === "trust-cert" ? t("trusting") || "Trusting…" : t("trustCert") || "Trust Cert"}
</button>
<a
href="/api/tools/agent-bridge/cert/download"
download
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/10 text-violet-600 px-3 py-1.5 text-xs font-medium hover:bg-violet-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">download</span>
{t("downloadCert") || "Download Cert"}
</a>
<button
type="button"
onClick={() => runAction("regenerate-cert")}
disabled={loading !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">autorenew</span>
{loading === "regenerate-cert"
? t("regenerating") || "Regenerating…"
: t("regenerateCert") || "Regenerate Cert"}
</button>
</div>
{/* Expanded: CA + Bypass */}
{expanded && (
<div className="px-5 pb-5 border-t border-border/30 pt-4 flex flex-col gap-5">
<UpstreamCaField
value={upstreamCa}
onChange={setUpstreamCa}
onSave={onUpstreamCaSave}
/>
<div>
<h4 className="text-xs font-semibold text-text-main mb-2">
{t("bypassSectionTitle") || "Bypass List"}
</h4>
<p className="text-xs text-text-muted mb-3">
{t("bypassSectionDesc") ||
"Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks, .gov, and corporate SSO."}
</p>
<BypassListEditor
patterns={bypassPatterns}
onSave={onBypassSave}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<void>;
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
/**
* 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 (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">search</span>
{t("statusInvestigating") || "Investigating"}
</span>
);
}
if (setupCompleted && dnsEnabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("statusActive") || "Active"}
</span>
);
}
if (!setupCompleted) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">settings</span>
{t("statusSetupRequired") || "Setup required"}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">warning</span>
{t("statusDnsOff") || "DNS off"}
</span>
);
};
const handleDnsToggle = async () => {
setDnsLoading(true);
try {
await onDnsToggle(target.id, !dnsEnabled);
} finally {
setDnsLoading(false);
}
};
return (
<>
<div
className="rounded-xl border border-border/50 bg-card overflow-hidden transition-all hover:border-border/80"
style={{ borderLeftWidth: 3, borderLeftColor: target.color }}
>
{/* Card header */}
<button
type="button"
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface/30 transition-colors"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<div className="flex items-center gap-3 min-w-0">
<AgentIcon icon={target.icon} color={target.color} size={18} />
<div className="min-w-0">
<p className="text-sm font-medium text-text-main truncate">{target.name}</p>
<p className="text-xs text-text-muted truncate">
{target.hosts.slice(0, 2).join(", ")}
{target.hosts.length > 2 && ` +${target.hosts.length - 2}`}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{getStatusBadge()}
<DnsStatusBadge enabled={dnsEnabled} />
<span className="material-symbols-outlined text-[16px] text-text-muted">
{expanded ? "expand_less" : "expand_more"}
</span>
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-4 border-t border-border/20 pt-4 flex flex-col gap-4">
{/* Hosts */}
<div>
<p className="text-xs font-medium text-text-muted mb-1.5">
{t("agentHosts") || "Intercepted hosts"}
</p>
<div className="flex flex-wrap gap-1.5">
{target.hosts.map((h) => (
<span
key={h}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs font-mono text-text-muted border border-border/40"
>
{h}
</span>
))}
</div>
</div>
{/* Cert status */}
<div className="flex items-center gap-2 text-xs text-text-muted">
<span
className={`material-symbols-outlined text-[14px] ${certTrusted ? "text-emerald-500" : "text-zinc-400"}`}
>
{certTrusted ? "verified_user" : "lock_open"}
</span>
{certTrusted
? t("certTrusted") || "Certificate trusted"
: t("certNotTrusted") || "Certificate not trusted"}
</div>
{/* Investigating notice */}
{isInvestigating && (
<div className="rounded-lg border border-zinc-500/20 bg-zinc-500/5 p-3">
<p className="text-xs text-text-muted">
{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."}
</p>
</div>
)}
{/* Model mappings */}
{!isInvestigating && (
<div>
<p className="text-xs font-medium text-text-muted mb-2">
{t("modelMappingsLabel") || "Model mappings"}
</p>
<ModelMappingTable
agentId={target.id}
mappings={mappings}
onSave={onMappingsSave}
/>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{!isInvestigating && (
<button
type="button"
onClick={() => setWizardOpen(true)}
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]">play_arrow</span>
{t("setupWizard") || "Setup wizard"}
</button>
)}
{!isInvestigating && (
<button
type="button"
onClick={handleDnsToggle}
disabled={dnsLoading}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${
dnsEnabled
? "bg-red-500/10 text-red-600 hover:bg-red-500/20"
: "bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{dnsEnabled ? "stop" : "play_arrow"}
</span>
{dnsLoading
? t("toggling") || "Toggling…"
: dnsEnabled
? t("stopDns") || "Stop DNS"
: t("startDns") || "Start DNS"}
</button>
)}
<a
href={`/dashboard/tools/traffic-inspector?agent=${target.id}`}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">network_check</span>
{t("viewTraffic") || "View traffic"}
</a>
</div>
</div>
)}
</div>
{wizardOpen && (
<SetupWizard
target={target}
agentState={agentState}
serverRunning={serverRunning}
onClose={() => setWizardOpen(false)}
onDnsToggle={onDnsToggle}
/>
)}
</>
);
}

View File

@@ -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<void>;
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
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<SetupFilter>("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 (
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
{/* Controls */}
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-border/30">
<h2 className="text-sm font-semibold text-text-main mr-auto">
{t("agentListTitle") || "IDE Agents"}{" "}
<span className="text-text-muted font-normal">({targets.length})</span>
</h2>
{/* Filter buttons */}
<div className="flex gap-1">
{filterOptions.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setFilter(opt.id)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
filter === opt.id
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text-main hover:bg-surface"
}`}
>
{opt.label}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-[16px] text-text-muted pointer-events-none">
search
</span>
<input
type="text"
className="rounded-lg border border-border/50 bg-surface pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("searchAgents") || "Search agents…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Grid */}
<div className="p-5 flex flex-col gap-3">
{filtered.length === 0 ? (
<div className="py-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[36px] block mb-2 text-text-muted/40">
search_off
</span>
<p className="text-sm">{t("noAgentsMatch") || "No agents match the current filter"}</p>
</div>
) : (
filtered.map((target) => (
<AgentCard
key={target.id}
target={target}
agentState={stateByAgent[target.id]}
serverRunning={serverRunning}
mappings={mappingsMap[target.id] ?? []}
onDnsToggle={onDnsToggle}
onMappingsSave={onMappingsSave}
/>
))
)}
</div>
</div>
);
}

View File

@@ -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<void>;
}
/**
* 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 (
<div className="flex flex-col gap-3">
<div>
<p className="text-xs font-medium text-text-muted mb-1.5">
{t("bypassDefaultsLabel") || "Default bypass patterns (read-only)"}
</p>
<div className="flex flex-wrap gap-1.5">
{DEFAULT_BYPASS_PATTERNS.map((p) => (
<span
key={p}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40"
>
{p}
</span>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">
{t("bypassUserLabel") || "Custom bypass patterns (one per line, glob or regex)"}
</label>
<textarea
className="w-full min-h-[80px] rounded-lg border border-border/50 bg-card px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="*.internal.corp&#10;sso.example.com"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary/10 text-primary px-4 py-2 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{saving ? t("saving") || "Saving…" : t("saveBypassList") || "Save bypass list"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
/**
* Empty state shown when no providers are configured.
* Matches plan 11 §7.
*/
export function EmptyStateNoProviders() {
const t = useTranslations("agentBridge");
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-card/50 px-8 py-14 text-center gap-4">
<div className="p-4 rounded-2xl bg-primary/10">
<span className="material-symbols-outlined text-[48px] text-primary">
dns
</span>
</div>
<div>
<h3 className="text-base font-semibold text-text-main mb-1">
{t("emptyNoProvidersTitle") || "No providers configured yet"}
</h3>
<p className="text-sm text-text-muted max-w-sm">
{t("emptyNoProvidersBody") ||
"To use AgentBridge, first connect at least one provider. It will be the destination where IDE requests are routed."}
</p>
</div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
{t("emptyGoToProviders") || "Go to Providers"}
</Link>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { ModelSelectorModal } from "./ModelSelectorModal";
export interface MappingRow {
source: string;
target: string;
}
interface ModelMappingTableProps {
agentId: string;
mappings: MappingRow[];
onSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
/**
* Editable table: source model → target OmniRoute model.
*/
export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTableProps) {
const t = useTranslations("agentBridge");
const [rows, setRows] = useState<MappingRow[]>(mappings);
const [selectorOpen, setSelectorOpen] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const updateTarget = (index: number, target: string) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, target } : r)));
setSelectorOpen(null);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave(agentId, rows);
} finally {
setSaving(false);
}
};
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">
<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>
<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>
{selectorOpen !== null && (
<ModelSelectorModal
open
currentModel={rows[selectorOpen]?.target ?? ""}
onSelect={(model) => updateTarget(selectorOpen, model)}
onClose={() => setSelectorOpen(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface ProviderModel {
id: string;
name: string;
}
interface ModelSelectorModalProps {
open: boolean;
currentModel: string;
onSelect: (model: string) => void;
onClose: () => void;
}
/**
* Modal for picking an OmniRoute target model for model-mapping.
*/
export function ModelSelectorModal({
open,
currentModel,
onSelect,
onClose,
}: ModelSelectorModalProps) {
const t = useTranslations("agentBridge");
const [models, setModels] = useState<ProviderModel[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const loadModels = useCallback(async () => {
try {
const r = await fetch("/api/v1/models");
const d = (await r.json()) as { data?: ProviderModel[] };
setModels(Array.isArray(d.data) ? d.data : []);
} catch {
setModels([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadModels();
}, [open, loadModels]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const filtered = models.filter(
(m) =>
m.id.toLowerCase().includes(search.toLowerCase()) ||
m.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-sm rounded-xl border border-border/60 bg-card shadow-xl flex flex-col max-h-[70vh]">
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-border/30">
<h3 className="text-sm font-semibold text-text-main">
{t("modelSelectorTitle") || "Select target model"}
</h3>
<button type="button" onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
close
</span>
</button>
</div>
<div className="px-4 py-2">
<input
type="text"
autoFocus
className="w-full rounded-lg border border-border/50 bg-surface px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("modelSelectorSearch") || "Search models…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4 flex flex-col gap-1">
{loading && (
<p className="text-xs text-text-muted py-4 text-center">
{t("loading") || "Loading models…"}
</p>
)}
{!loading && filtered.length === 0 && (
<p className="text-xs text-text-muted py-4 text-center">
{t("noModelsFound") || "No models found"}
</p>
)}
{filtered.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
m.id === currentModel
? "bg-primary/10 text-primary font-medium"
: "hover:bg-surface text-text-main"
}`}
>
<span className="font-mono text-xs">{m.id}</span>
{m.name !== m.id && (
<span className="ml-2 text-text-muted text-xs">{m.name}</span>
)}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
const STORAGE_KEY = "omniroute-agentbridge-risk-dismissed";
function isNotDismissed(): boolean {
try {
return !localStorage.getItem(STORAGE_KEY);
} catch {
return true;
}
}
/**
* Amber dismissable banner shown at the top of the AgentBridge page.
* Persisted via localStorage so it only shows once per user.
* Uses lazy useState initializer to read localStorage without useEffect.
*/
export function RiskNoticeBanner() {
const t = useTranslations("agentBridge");
const [visible, setVisible] = useState<boolean>(isNotDismissed);
const dismiss = () => {
try {
localStorage.setItem(STORAGE_KEY, "true");
} catch {
// ignore
}
setVisible(false);
};
if (!visible) return null;
return (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3"
>
<span className="material-symbols-outlined text-amber-500 shrink-0 mt-0.5">warning</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-400">
{t("riskBannerTitle") || "Use at your own risk"}
</p>
<p className="text-xs text-amber-600/80 dark:text-amber-300/70 mt-0.5">
{t("riskBannerBody") ||
"AgentBridge intercepts HTTPS traffic from IDE agents. By activating it you accept responsibility for compliance with the terms of service of each agent. Never use on devices or networks where TLS inspection is prohibited."}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("riskBannerDismiss") || "Dismiss"}
className="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MitmTarget } from "@/mitm/types";
interface SetupWizardProps {
target: MitmTarget;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
onClose: () => void;
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
}
type Step = "verify" | "dns" | "mappings";
/**
* 3-step setup wizard for a single agent.
* Step 1: Verify server + cert
* Step 2: Enable DNS
* Step 3: Model mappings prompt
*/
export function SetupWizard({
target,
agentState,
serverRunning,
onClose,
onDnsToggle,
}: SetupWizardProps) {
const t = useTranslations("agentBridge");
const [step, setStep] = useState<Step>("verify");
const [enablingDns, setEnablingDns] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const certTrusted = agentState?.cert_trusted ?? false;
const dnsEnabled = agentState?.dns_enabled ?? false;
const handleEnableDns = async () => {
setEnablingDns(true);
try {
await onDnsToggle(target.id, true);
setStep("mappings");
} finally {
setEnablingDns(false);
}
};
const steps: { id: Step; label: string }[] = [
{ id: "verify", label: t("wizardStep1Label") || "Verify" },
{ id: "dns", label: t("wizardStep2Label") || "DNS" },
{ id: "mappings", label: t("wizardStep3Label") || "Mappings" },
];
const stepIndex = steps.findIndex((s) => s.id === step);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-lg rounded-xl border border-border/60 bg-card shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-4 border-b border-border/30">
<div className="flex items-center gap-3">
<span
className="material-symbols-outlined text-[20px]"
style={{ color: target.color }}
>
{target.icon}
</span>
<div>
<h3 className="text-sm font-semibold text-text-main">
{t("wizardTitle") || "Setup wizard"} {target.name}
</h3>
<p className="text-xs text-text-muted">{t("wizardSubtitle") || "3-step setup"}</p>
</div>
</div>
<button type="button" onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
close
</span>
</button>
</div>
{/* Step indicator */}
<div className="flex px-5 pt-4 gap-2">
{steps.map((s, i) => (
<div key={s.id} className="flex items-center gap-1.5 flex-1">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium shrink-0 ${
i < stepIndex
? "bg-emerald-500 text-white"
: i === stepIndex
? "bg-primary text-white"
: "bg-surface text-text-muted border border-border/50"
}`}
>
{i < stepIndex ? (
<span className="material-symbols-outlined text-[12px]">check</span>
) : (
i + 1
)}
</div>
<span
className={`text-xs ${i === stepIndex ? "text-text-main font-medium" : "text-text-muted"}`}
>
{s.label}
</span>
{i < steps.length - 1 && (
<div className="flex-1 h-px bg-border/30 ml-1" />
)}
</div>
))}
</div>
{/* Step content */}
<div className="px-5 py-5 flex flex-col gap-4 min-h-[180px]">
{step === "verify" && (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
{t("wizardStep1Desc") || "Confirm the server is running and the certificate is installed."}
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm">
<span
className={`material-symbols-outlined text-[16px] ${serverRunning ? "text-emerald-500" : "text-red-500"}`}
>
{serverRunning ? "check_circle" : "cancel"}
</span>
<span>
{t("wizardServerCheck") || "AgentBridge server"}{" "}
{serverRunning
? t("wizardRunning") || "running"
: t("wizardNotRunning") || "not running"}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span
className={`material-symbols-outlined text-[16px] ${certTrusted ? "text-emerald-500" : "text-amber-500"}`}
>
{certTrusted ? "verified_user" : "warning"}
</span>
<span>
{t("wizardCertCheck") || "Certificate"}{" "}
{certTrusted
? t("wizardTrusted") || "trusted"
: t("wizardNotTrusted") || "not yet trusted — use Trust Cert button"}
</span>
</div>
</div>
{/* Tutorial steps */}
{target.setupTutorial.steps.length > 0 && (
<div className="mt-2 p-3 rounded-lg bg-surface/50 border border-border/30">
<p className="text-xs font-medium text-text-muted mb-2">
{t("wizardTutorialTitle") || "Setup instructions:"}
</p>
<ol className="flex flex-col gap-1">
{target.setupTutorial.steps.map((step, i) => (
<li key={i} className="text-xs text-text-muted flex items-start gap-1.5">
<span className="shrink-0 text-primary font-medium">{i + 1}.</span>
{step}
</li>
))}
</ol>
</div>
)}
</div>
)}
{step === "dns" && (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
{t("wizardStep2Desc") || "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:"}
</p>
<div className="rounded-lg bg-surface/50 border border-border/30 p-3 font-mono text-xs flex flex-col gap-1">
{target.hosts.map((host) => (
<div key={host} className="text-text-muted">
<span className="text-primary">127.0.0.1</span> {host}
</div>
))}
</div>
{dnsEnabled && (
<div className="flex items-center gap-2 text-sm text-emerald-500">
<span className="material-symbols-outlined text-[16px]">check_circle</span>
{t("wizardDnsAlreadyEnabled") || "DNS already enabled for this agent"}
</div>
)}
</div>
)}
{step === "mappings" && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">check_circle</span>
<p className="text-sm font-medium">
{t("wizardStep3Success") || "Agent is configured!"}
</p>
</div>
<p className="text-sm text-text-muted">
{t("wizardStep3Desc") || "You can now configure model mappings in the agent card. Restart the IDE to apply changes."}
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 pb-5 pt-0 border-t border-border/30 mt-0 pt-4">
<button
type="button"
onClick={() => {
if (step === "dns") setStep("verify");
else if (step === "mappings") setStep("dns");
else onClose();
}}
className="rounded-lg border border-border/50 bg-card px-4 py-2 text-sm text-text-muted hover:bg-surface transition-colors"
>
{step === "verify" ? t("cancel") || "Cancel" : t("back") || "Back"}
</button>
<div className="flex gap-2">
{step === "verify" && (
<button
type="button"
onClick={() => setStep("dns")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
{t("next") || "Next"}{" "}
<span className="material-symbols-outlined text-[14px] ml-1">arrow_forward</span>
</button>
)}
{step === "dns" && (
<>
{dnsEnabled ? (
<button
type="button"
onClick={() => setStep("mappings")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
{t("next") || "Next"}
</button>
) : (
<button
type="button"
onClick={handleEnableDns}
disabled={enablingDns}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{enablingDns
? t("enablingDns") || "Enabling…"
: t("wizardEnableDns") || "Add /etc/hosts entries"}
</button>
)}
</>
)}
{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") || "Done"}
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface UpstreamCaFieldProps {
value: string;
onChange: (v: string) => void;
onSave: (path: string) => Promise<void>;
}
/**
* Input + Test button for the optional upstream CA certificate path.
* Used for corporate networks that intercept TLS upstream.
*/
export function UpstreamCaField({ value, onChange, onSave }: UpstreamCaFieldProps) {
const t = useTranslations("agentBridge");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"ok" | "error" | null>(null);
const handleTest = async () => {
if (!value.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/tools/agent-bridge/upstream-ca/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: value.trim() }),
});
setTestResult(res.ok ? "ok" : "error");
} catch {
setTestResult("error");
} finally {
setTesting(false);
}
};
const handleSave = async () => {
await onSave(value.trim());
};
return (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-text-muted">
{t("upstreamCaLabel") || "Upstream CA Certificate (corporate)"}
</label>
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("upstreamCaPlaceholder") || "/etc/ssl/certs/corp-ca.pem"}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
onClick={handleTest}
disabled={testing || !value.trim()}
className="shrink-0 rounded-lg border border-border/50 bg-card px-3 py-2 text-xs font-medium hover:bg-surface transition-colors disabled:opacity-50"
>
{testing ? "Testing…" : t("upstreamCaTest") || "Test TLS"}
</button>
<button
type="button"
onClick={handleSave}
disabled={!value.trim()}
className="shrink-0 rounded-lg bg-primary/10 text-primary px-3 py-2 text-xs font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{t("save") || "Save"}
</button>
</div>
{testResult === "ok" && (
<p className="text-xs text-emerald-500">
<span className="material-symbols-outlined text-[12px] mr-1">check_circle</span>
{t("upstreamCaTestOk") || "TLS test passed"}
</p>
)}
{testResult === "error" && (
<p className="text-xs text-red-500">
<span className="material-symbols-outlined text-[12px] mr-1">error</span>
{t("upstreamCaTestError") || "TLS test failed — check the path and CA file"}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
interface AgentIconProps {
icon: string;
color: string;
size?: number;
}
export function AgentIcon({ icon, color, size = 20 }: AgentIconProps) {
return (
<div
className="flex items-center justify-center rounded-lg shrink-0"
style={{
backgroundColor: `${color}20`,
width: size + 12,
height: size + 12,
}}
>
<span
className="material-symbols-outlined"
style={{ fontSize: size, color }}
>
{icon}
</span>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
interface CertStatusIconProps {
trusted: boolean;
size?: number;
}
export function CertStatusIcon({ trusted, size = 16 }: CertStatusIconProps) {
return trusted ? (
<span
className="material-symbols-outlined text-emerald-500"
style={{ fontSize: size }}
title="Certificate trusted"
>
verified_user
</span>
) : (
<span
className="material-symbols-outlined text-zinc-400"
style={{ fontSize: size }}
title="Certificate not trusted"
>
lock_open
</span>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
interface DnsStatusBadgeProps {
enabled: boolean;
}
export function DnsStatusBadge({ enabled }: DnsStatusBadgeProps) {
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
enabled
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${enabled ? "bg-emerald-500" : "bg-zinc-400"}`}
/>
{enabled ? "DNS on" : "DNS off"}
</span>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentBridgePageData } from "../AgentBridgePageClient";
interface UseAgentBridgeStateOptions {
initialData: AgentBridgePageData;
pollingInterval?: number;
}
interface UseAgentBridgeStateReturn {
data: AgentBridgePageData;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/**
* Hook for fetching and revalidating AgentBridge page data.
* Uses fetch + polling (no SWR dependency) — project pattern from cloud-agents.
*/
export function useAgentBridgeState({
initialData,
pollingInterval = 5000,
}: UseAgentBridgeStateOptions): UseAgentBridgeStateReturn {
const [data, setData] = useState<AgentBridgePageData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/tools/agent-bridge/state", {
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as AgentBridgePageData;
if (!ctrl.signal.aborted) setData(json);
} catch (err) {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}, []);
// Auto-poll
useEffect(() => {
if (!pollingInterval || pollingInterval <= 0) return;
const id = setInterval(() => {
refresh().catch(() => {/* swallow background errors */});
}, pollingInterval);
return () => clearInterval(id);
}, [pollingInterval, refresh]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return { data, loading, error, refresh };
}

View File

@@ -0,0 +1,61 @@
import { getProviderConnections } from "@/lib/db/providers";
import { ALL_TARGETS } from "@/mitm/targets/index";
import AgentBridgePageClient from "./AgentBridgePageClient";
import type { AgentBridgePageData } from "./AgentBridgePageClient";
/**
* AgentBridge page — Server Component entry point.
* Fetches initial state from the backend API and passes to client orchestrator.
*/
export default async function AgentBridgePage() {
// Check if any providers are configured (D15)
let hasProviders = false;
try {
const connections = await getProviderConnections();
hasProviders = Array.isArray(connections) && connections.length > 0;
} catch {
// If DB not ready yet, show empty state gracefully
hasProviders = false;
}
// Fetch initial AgentBridge state from the REST API
// Falls back to a safe default if the API isn't ready yet
let initialData: AgentBridgePageData = {
serverState: {
running: false,
port: 443,
certTrusted: false,
upstreamCa: null,
lastStartedAt: null,
activeConns: 0,
interceptedCount: 0,
},
agentStates: [],
bypassPatterns: [],
mappings: {},
};
try {
const base =
process.env.OMNIROUTE_BASE_URL ??
`http://127.0.0.1:${process.env.PORT ?? 20128}`;
const res = await fetch(`${base}/api/tools/agent-bridge/state`, {
cache: "no-store",
headers: { "x-internal-fetch": "1" },
});
if (res.ok) {
const json = (await res.json()) as AgentBridgePageData;
initialData = json;
}
} catch {
// Backend not yet available — use defaults; client will poll
}
return (
<AgentBridgePageClient
initialData={initialData}
targets={ALL_TARGETS}
hasProviders={hasProviders}
/>
);
}

View File

@@ -877,6 +877,8 @@
"cliToolsSubtitle": "Configure CLI runtimes",
"agentsSubtitle": "Manage local agents",
"cloudAgentsSubtitle": "Manage cloud-based agents",
"agentBridge": "AgentBridge",
"agentBridgeSubtitle": "MITM interceptor for IDE agents",
"apiEndpointsSubtitle": "Expose custom endpoints",
"proxySubtitle": "HTTP proxy settings",
"mitmProxySubtitle": "MITM interception",
@@ -7289,5 +7291,101 @@
"policyLabel": "Policy:",
"resetIn": "reset in",
"quotaTotal": "total"
},
"agentBridge": {
"title": "AgentBridge",
"subtitle": "Use IDE agents with OmniRoute models — no config required",
"riskBannerTitle": "Use at your own risk",
"riskBannerBody": "AgentBridge intercepts HTTPS traffic from IDE agents. By activating it you accept responsibility for compliance with the terms of service of each agent. Never use on devices or networks where TLS inspection is prohibited.",
"riskBannerDismiss": "Dismiss",
"serverCardTitle": "AgentBridge Server",
"statusRunning": "Running",
"statusStopped": "Stopped",
"statusActive": "Active",
"statusDnsOff": "DNS off",
"statusSetupRequired": "Setup required",
"statusInvestigating": "Investigating",
"serverPort": "Port",
"serverConns": "Connections",
"serverIntercepted": "Intercepted",
"serverLastStarted": "Last started",
"startServer": "Start",
"stopServer": "Stop",
"restartServer": "Restart",
"trustCert": "Trust Cert",
"downloadCert": "Download Cert",
"regenerateCert": "Regenerate Cert",
"starting": "Starting…",
"stopping": "Stopping…",
"restarting": "Restarting…",
"trusting": "Trusting…",
"regenerating": "Regenerating…",
"upstreamCaLabel": "Upstream CA Certificate (corporate)",
"upstreamCaPlaceholder": "/etc/ssl/certs/corp-ca.pem",
"upstreamCaTest": "Test TLS",
"upstreamCaTestOk": "TLS test passed",
"upstreamCaTestError": "TLS test failed — check the path and CA file",
"bypassSectionTitle": "Bypass List",
"bypassSectionDesc": "Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks, .gov, and corporate SSO.",
"bypassDefaultsLabel": "Default bypass patterns (read-only)",
"bypassUserLabel": "Custom bypass patterns (one per line, glob or regex)",
"saveBypassList": "Save bypass list",
"agentListTitle": "IDE Agents",
"filterAll": "All",
"filterActive": "Active",
"filterSetupRequired": "Setup required",
"filterInvestigating": "Investigating",
"searchAgents": "Search agents…",
"noAgentsMatch": "No agents match the current filter",
"agentHosts": "Intercepted hosts",
"certTrusted": "Certificate trusted",
"certNotTrusted": "Certificate not trusted",
"investigatingNotice": "This agent is under investigation. Hosts and API surface are still being confirmed. Setup will be available once the upstream API is documented.",
"modelMappingsLabel": "Model mappings",
"sourceModel": "Source model (agent native)",
"targetModel": "Target model (OmniRoute)",
"noMappings": "No model mappings configured. Run setup wizard to auto-detect models.",
"selectModel": "Select…",
"saveMappings": "Save mappings",
"setupWizard": "Setup wizard",
"startDns": "Start DNS",
"stopDns": "Stop DNS",
"toggling": "Toggling…",
"viewTraffic": "View traffic",
"emptyNoProvidersTitle": "No providers configured yet",
"emptyNoProvidersBody": "To use AgentBridge, first connect at least one provider. It will be the destination where IDE requests are routed.",
"emptyGoToProviders": "Go to Providers",
"wizardTitle": "Setup wizard",
"wizardSubtitle": "3-step setup",
"wizardStep1Label": "Verify",
"wizardStep2Label": "DNS",
"wizardStep3Label": "Mappings",
"wizardStep1Desc": "Confirm the server is running and the certificate is installed.",
"wizardStep2Desc": "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:",
"wizardStep3Desc": "You can now configure model mappings in the agent card. Restart the IDE to apply changes.",
"wizardStep3Success": "Agent is configured!",
"wizardServerCheck": "AgentBridge server",
"wizardRunning": "running",
"wizardNotRunning": "not running",
"wizardCertCheck": "Certificate",
"wizardTrusted": "trusted",
"wizardNotTrusted": "not yet trusted — use Trust Cert button",
"wizardTutorialTitle": "Setup instructions:",
"wizardEnableDns": "Add /etc/hosts entries",
"wizardDnsAlreadyEnabled": "DNS already enabled for this agent",
"enablingDns": "Enabling…",
"modelSelectorTitle": "Select target model",
"modelSelectorSearch": "Search models…",
"noModelsFound": "No models found",
"quickLinks": "Quick links",
"quickLinkProviders": "Configure providers",
"quickLinkInspector": "View traffic in Traffic Inspector",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"back": "Back",
"next": "Next",
"done": "Done",
"loading": "Loading…"
}
}

View File

@@ -877,6 +877,8 @@
"cliToolsSubtitle": "Configurar runtimes CLI",
"agentsSubtitle": "Gerenciar agentes locais",
"cloudAgentsSubtitle": "Gerenciar agentes na nuvem",
"agentBridge": "AgentBridge",
"agentBridgeSubtitle": "Interceptador MITM para agentes de IDE",
"apiEndpointsSubtitle": "Expor endpoints customizados",
"proxySubtitle": "Configurações do proxy HTTP",
"mitmProxySubtitle": "Interceptação MITM",
@@ -7279,5 +7281,101 @@
"policyLabel": "Política:",
"resetIn": "redefinir em",
"quotaTotal": "total"
},
"agentBridge": {
"title": "AgentBridge",
"subtitle": "Use agentes de IDE com modelos do OmniRoute — sem configuração manual",
"riskBannerTitle": "Use por sua conta e risco",
"riskBannerBody": "O AgentBridge intercepta o tráfego HTTPS dos agentes de IDE. Ao ativá-lo, você aceita a responsabilidade pela conformidade com os termos de serviço de cada agente. Nunca use em dispositivos ou redes onde a inspeção TLS é proibida.",
"riskBannerDismiss": "Dispensar",
"serverCardTitle": "Servidor AgentBridge",
"statusRunning": "Rodando",
"statusStopped": "Parado",
"statusActive": "Ativo",
"statusDnsOff": "DNS desligado",
"statusSetupRequired": "Configuração necessária",
"statusInvestigating": "Investigando",
"serverPort": "Porta",
"serverConns": "Conexões",
"serverIntercepted": "Interceptados",
"serverLastStarted": "Iniciado em",
"startServer": "Iniciar",
"stopServer": "Parar",
"restartServer": "Reiniciar",
"trustCert": "Confiar Cert",
"downloadCert": "Baixar Cert",
"regenerateCert": "Regenerar Cert",
"starting": "Iniciando…",
"stopping": "Parando…",
"restarting": "Reiniciando…",
"trusting": "Confiando…",
"regenerating": "Regenerando…",
"upstreamCaLabel": "Certificado CA Upstream (corporativo)",
"upstreamCaPlaceholder": "/etc/ssl/certs/corp-ca.pem",
"upstreamCaTest": "Testar TLS",
"upstreamCaTestOk": "Teste TLS passou",
"upstreamCaTestError": "Teste TLS falhou — verifique o caminho e o arquivo CA",
"bypassSectionTitle": "Lista de Bypass",
"bypassSectionDesc": "Hosts correspondentes a estes padrões são tunelados diretamente (sem descriptografia TLS). Os padrões padrão incluem bancos, .gov e SSO corporativo.",
"bypassDefaultsLabel": "Padrões de bypass padrão (somente leitura)",
"bypassUserLabel": "Padrões de bypass personalizados (um por linha, glob ou regex)",
"saveBypassList": "Salvar lista de bypass",
"agentListTitle": "Agentes de IDE",
"filterAll": "Todos",
"filterActive": "Ativos",
"filterSetupRequired": "Configuração necessária",
"filterInvestigating": "Investigando",
"searchAgents": "Buscar agentes…",
"noAgentsMatch": "Nenhum agente corresponde ao filtro atual",
"agentHosts": "Hosts interceptados",
"certTrusted": "Certificado confiável",
"certNotTrusted": "Certificado não confiável",
"investigatingNotice": "Este agente está sob investigação. Os hosts e a superfície de API ainda estão sendo confirmados. A configuração estará disponível assim que a API upstream for documentada.",
"modelMappingsLabel": "Mapeamentos de modelo",
"sourceModel": "Modelo de origem (nativo do agente)",
"targetModel": "Modelo alvo (OmniRoute)",
"noMappings": "Nenhum mapeamento de modelo configurado. Execute o assistente de configuração para detectar modelos automaticamente.",
"selectModel": "Selecionar…",
"saveMappings": "Salvar mapeamentos",
"setupWizard": "Assistente de configuração",
"startDns": "Ativar DNS",
"stopDns": "Desativar DNS",
"toggling": "Alternando…",
"viewTraffic": "Ver tráfego",
"emptyNoProvidersTitle": "Nenhum provedor configurado ainda",
"emptyNoProvidersBody": "Para usar o AgentBridge, primeiro conecte pelo menos um provedor. Ele será o destino para onde os requests dos IDEs serão roteados.",
"emptyGoToProviders": "Ir para Provedores",
"wizardTitle": "Assistente de configuração",
"wizardSubtitle": "Configuração em 3 passos",
"wizardStep1Label": "Verificar",
"wizardStep2Label": "DNS",
"wizardStep3Label": "Mapeamentos",
"wizardStep1Desc": "Confirme que o servidor está rodando e o certificado está instalado.",
"wizardStep2Desc": "As seguintes entradas serão adicionadas ao /etc/hosts para redirecionar o tráfego pelo AgentBridge:",
"wizardStep3Desc": "Agora você pode configurar mapeamentos de modelo no card do agente. Reinicie o IDE para aplicar as alterações.",
"wizardStep3Success": "Agente configurado!",
"wizardServerCheck": "Servidor AgentBridge",
"wizardRunning": "rodando",
"wizardNotRunning": "não rodando",
"wizardCertCheck": "Certificado",
"wizardTrusted": "confiável",
"wizardNotTrusted": "ainda não confiável — use o botão Confiar Cert",
"wizardTutorialTitle": "Instruções de configuração:",
"wizardEnableDns": "Adicionar entradas ao /etc/hosts",
"wizardDnsAlreadyEnabled": "DNS já ativado para este agente",
"enablingDns": "Ativando…",
"modelSelectorTitle": "Selecionar modelo alvo",
"modelSelectorSearch": "Buscar modelos…",
"noModelsFound": "Nenhum modelo encontrado",
"quickLinks": "Atalhos",
"quickLinkProviders": "Configurar provedores",
"quickLinkInspector": "Ver tráfego no Traffic Inspector",
"save": "Salvar",
"saving": "Salvando…",
"cancel": "Cancelar",
"back": "Voltar",
"next": "Próximo",
"done": "Concluído",
"loading": "Carregando…"
}
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/Button";
export interface RiskNoticeModalProps {
open: boolean;
title: string;
body: string;
dontShowAgainKey: string;
onAccept: () => void;
onCancel: () => void;
}
/**
* Generic risk notice modal (D16).
* Persists "don't show again" preference to localStorage using `dontShowAgainKey`.
*/
export function RiskNoticeModal({
open,
title,
body,
dontShowAgainKey,
onAccept,
onCancel,
}: RiskNoticeModalProps) {
const t = useTranslations("common");
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onCancel]);
if (!open) return null;
const handleAccept = () => {
try {
localStorage.setItem(dontShowAgainKey, "true");
} catch {
// ignore storage errors
}
onAccept();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="risk-modal-title"
>
<div className="w-full max-w-md rounded-xl border border-amber-500/30 bg-card p-6 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">warning</span>
</div>
<h2 id="risk-modal-title" className="text-base font-semibold text-text-main pt-1">
{title}
</h2>
</div>
<p className="text-sm text-text-muted mb-6 leading-relaxed">{body}</p>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={onCancel}>
{t("cancel") || "Cancel"}
</Button>
<Button variant="primary" onClick={handleAccept}>
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
{t("understand") || "I understand"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"cli-tools",
"agents",
"cloud-agents",
"agent-bridge",
// OmniProxy > Integrations
"api-endpoints",
"webhooks",
@@ -249,6 +250,13 @@ const TOOLS_GROUP: SidebarItemGroup = {
subtitleKey: "cloudAgentsSubtitle",
icon: "cloud",
},
{
id: "agent-bridge",
href: "/dashboard/tools/agent-bridge",
i18nKey: "agentBridge",
subtitleKey: "agentBridgeSubtitle",
icon: "link",
},
],
};

View File

@@ -0,0 +1,200 @@
// @vitest-environment jsdom
/**
* UI unit tests for AgentBridge page — smoke render + empty state.
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
React.createElement("a", { href }, children),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
// Mock fetch for hooks
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
} as unknown as Response);
// ── Helpers ───────────────────────────────────────────────────────────────────
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("EmptyStateNoProviders", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders empty state component with link to providers", async () => {
const { EmptyStateNoProviders } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/EmptyStateNoProviders"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(React.createElement(EmptyStateNoProviders));
});
expect(document.body.innerHTML).toContain("emptyNoProvidersTitle");
expect(document.body.innerHTML).toContain("/dashboard/providers");
});
});
describe("RiskNoticeBanner", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
try { localStorage.clear(); } catch { /* ignore */ }
});
it("renders risk notice banner when not dismissed", async () => {
// Ensure not dismissed
try { localStorage.removeItem("omniroute-agentbridge-risk-dismissed"); } catch { /* ignore */ }
const { RiskNoticeBanner } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(React.createElement(RiskNoticeBanner));
});
// Banner is rendered (useEffect fires synchronously in jsdom with act)
// It shows "riskBannerTitle" i18n key
expect(document.body.innerHTML).toContain("riskBannerTitle");
});
it("does not render risk banner when already dismissed", async () => {
try {
localStorage.setItem("omniroute-agentbridge-risk-dismissed", "true");
} catch { /* ignore */ }
const { RiskNoticeBanner } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(React.createElement(RiskNoticeBanner));
});
expect(document.body.innerHTML).not.toContain("riskBannerTitle");
});
it("dismisses banner on close click", async () => {
try { localStorage.removeItem("omniroute-agentbridge-risk-dismissed"); } catch { /* ignore */ }
const { RiskNoticeBanner } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(React.createElement(RiskNoticeBanner));
});
// Click dismiss
const closeBtn = container.querySelector('button[aria-label]');
await act(async () => {
closeBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Banner gone
expect(document.body.innerHTML).not.toContain("riskBannerTitle");
});
});
describe("BypassListEditor", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders default bypass patterns", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: [],
onSave: vi.fn(),
})
);
});
expect(document.body.innerHTML).toContain("*.bank.*");
expect(document.body.innerHTML).toContain("*.okta.com");
});
it("renders initial user patterns in textarea", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: ["*.internal.corp"],
onSave: vi.fn(),
})
);
});
const textarea = container.querySelector("textarea");
expect(textarea?.value).toContain("*.internal.corp");
});
});

View File

@@ -0,0 +1,207 @@
// @vitest-environment jsdom
/**
* UI unit tests for AgentCard — DNS toggle + wizard open.
*
* Note: AgentCard/SetupWizard import @/mitm/types (zod types only, no DB).
* We set testTimeout=30000 to handle the initial transform overhead.
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
React.createElement("a", { href }, children),
}));
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
} as unknown as Response);
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
// Minimal mock target (matches MitmTarget shape but no heavy imports)
const mockTarget = {
id: "copilot" as const,
name: "GitHub Copilot",
icon: "code",
color: "#10B981",
hosts: ["api.githubcopilot.com"],
port: 443,
endpointPatterns: ["/chat/completions"],
defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }],
setupTutorial: {
steps: ["Step 1", "Step 2"],
detection: { command: "which copilot", platform: "all" as const },
},
handler: () => Promise.resolve({ default: class {} as never }),
riskNoticeKey: "providers.riskNotice.oauth",
viability: "supported" as const,
};
describe("AgentCard", { timeout: 30000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders agent name and hosts", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: undefined,
serverRunning: false,
mappings: [],
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});
expect(document.body.innerHTML).toContain("GitHub Copilot");
expect(document.body.innerHTML).toContain("api.githubcopilot.com");
}, 30000);
it("expands on click and shows DNS toggle", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: undefined,
serverRunning: true,
mappings: [],
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});
const header = container.querySelector("button[aria-expanded]");
expect(header).not.toBeNull();
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.innerHTML).toContain("startDns");
}, 30000);
it("calls onDnsToggle when DNS button clicked", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: {
agent_id: "copilot",
dns_enabled: false,
cert_trusted: false,
setup_completed: false,
last_started_at: null,
last_error: null,
},
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Find and click DNS button
const dnsButton = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
expect(dnsButton).not.toBeNull();
await act(async () => {
dnsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
}, 30000);
it("opens wizard when setup wizard button clicked", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: undefined,
serverRunning: true,
mappings: [],
onDnsToggle: vi.fn(),
onMappingsSave: vi.fn(),
})
);
});
// Expand card first
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Find setup wizard button
const wizardBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("setupWizard")
);
await act(async () => {
wizardBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
}, 30000);
});

View File

@@ -0,0 +1,128 @@
// @vitest-environment jsdom
/**
* UI unit tests for BypassListEditor — add/remove patterns.
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
describe("BypassListEditor", { timeout: 30000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders default bypass patterns as read-only chips", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: [],
onSave: vi.fn(),
})
);
});
expect(document.body.innerHTML).toContain("*.bank.*");
expect(document.body.innerHTML).toContain("*.okta.com");
}, 30000);
it("renders initial user patterns in textarea", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: ["*.internal.corp", "sso.example.com"],
onSave: vi.fn(),
})
);
});
const textarea = container.querySelector("textarea");
expect(textarea?.value).toContain("*.internal.corp");
expect(textarea?.value).toContain("sso.example.com");
}, 30000);
it("calls onSave when Save button clicked", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const onSave = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: ["existing.com"],
onSave,
})
);
});
const saveBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("saveBypassList")
);
expect(saveBtn).not.toBeNull();
await act(async () => {
saveBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSave).toHaveBeenCalled();
}, 30000);
it("renders save button", async () => {
const { BypassListEditor } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(BypassListEditor, {
patterns: [],
onSave: vi.fn(),
})
);
});
const saveBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("saveBypassList")
);
expect(saveBtn).not.toBeNull();
}, 30000);
});

View File

@@ -0,0 +1,187 @@
// @vitest-environment jsdom
/**
* UI unit tests for SetupWizard — 3-step flow.
* Timeout raised to 30000ms to handle initial module transform overhead.
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
const mockTarget = {
id: "kiro" as const,
name: "Kiro",
icon: "smart_toy",
color: "#FF9900",
hosts: ["prod.kiro.aws"],
port: 443,
endpointPatterns: ["/v1/chat/completions"],
defaultModels: [],
setupTutorial: {
steps: ["Install Kiro", "Enable trust cert", "Activate DNS"],
detection: { command: "which kiro", platform: "all" as const },
},
handler: () => Promise.resolve({ default: class {} as never }),
riskNoticeKey: "providers.riskNotice.oauth",
};
describe("SetupWizard", { timeout: 30000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders step 1 (verify) on open", async () => {
const { SetupWizard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(SetupWizard, {
target: mockTarget,
agentState: undefined,
serverRunning: false,
onClose: vi.fn(),
onDnsToggle: vi.fn(),
})
);
});
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
expect(document.body.innerHTML).toContain("wizardStep1Label");
expect(document.body.innerHTML).toContain("wizardStep1Desc");
}, 30000);
it("navigates to step 2 when Next clicked", async () => {
const { SetupWizard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard"
);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(SetupWizard, {
target: mockTarget,
agentState: undefined,
serverRunning: true,
onClose: vi.fn(),
onDnsToggle: vi.fn(),
})
);
});
const nextBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("next")
);
expect(nextBtn).not.toBeNull();
await act(async () => {
nextBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.innerHTML).toContain("wizardStep2Desc");
}, 30000);
it("calls onDnsToggle when enabling DNS in step 2", async () => {
const { SetupWizard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(SetupWizard, {
target: mockTarget,
agentState: {
agent_id: "kiro",
dns_enabled: false,
cert_trusted: false,
setup_completed: false,
last_started_at: null,
last_error: null,
},
serverRunning: true,
onClose: vi.fn(),
onDnsToggle,
})
);
});
// Navigate to step 2
const nextBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("next")
);
await act(async () => {
nextBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const enableDnsBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("wizardEnableDns")
);
expect(enableDnsBtn).not.toBeNull();
await act(async () => {
enableDnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onDnsToggle).toHaveBeenCalledWith("kiro", true);
}, 30000);
it("calls onClose when Cancel clicked on step 1", async () => {
const { SetupWizard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard"
);
const onClose = vi.fn();
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(SetupWizard, {
target: mockTarget,
agentState: undefined,
serverRunning: false,
onClose,
onDnsToggle: vi.fn(),
})
);
});
const cancelBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("cancel")
);
await act(async () => {
cancelBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onClose).toHaveBeenCalled();
}, 30000);
});