feat(api): agent-bridge state + server + agents + cert + bypass + upstream-ca routes (F5)

12 REST routes under /api/tools/agent-bridge/ covering all AgentBridge
backend surfaces: server lifecycle, per-agent state/DNS/mappings/detect,
cert status/download/regenerate, bypass pattern CRUD, upstream CA config.
All routes use Zod validation and route errors through sanitizeErrorMessage.

feat(authz): mark agent-bridge LOCAL_ONLY + SPAWN_CAPABLE (F5)

Adds /api/tools/agent-bridge/ to both LOCAL_ONLY_API_PREFIXES and
SPAWN_CAPABLE_PREFIXES in routeGuard.ts — satisfying Hard Rules #15 + #17.
This commit is contained in:
diegosouzapw
2026-05-28 00:24:38 -03:00
parent cb6a8c1641
commit 19c4ff9bb0
13 changed files with 588 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
/**
* GET /api/tools/agent-bridge/agents/[id]/detect
* Run detection probe for an agent and return { installed, version?, path? }.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { detectAgent } from "@/mitm/detection/index";
import type { AgentId } from "@/mitm/types";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const VALID_IDS = new Set<AgentId>([
"antigravity",
"kiro",
"copilot",
"codex",
"cursor",
"zed",
"claude-code",
"open-code",
"trae",
]);
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = params;
if (!VALID_IDS.has(id as AgentId)) {
return createErrorResponse({ status: 404, message: `Unknown agent id: ${id}` });
}
try {
const result = detectAgent(id as AgentId);
return Response.json({ agentId: id, ...result });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,55 @@
/**
* POST /api/tools/agent-bridge/agents/[id]/dns
* Enable or disable DNS entries for a specific agent.
* LOCAL_ONLY + SPAWN_CAPABLE: registered in routeGuard.ts
*
* Body: AgentBridgeDnsActionSchema { enabled: boolean }
*/
import { AgentBridgeDnsActionSchema } from "@/shared/schemas/agentBridge";
import { addDNSEntry, removeDNSEntry } from "@/mitm/dns/dnsConfig";
import { upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import { getCachedPassword } from "@/mitm/manager";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
type Params = { params: { id: string } };
export async function POST(request: Request, { params }: Params): Promise<Response> {
const { id } = params;
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeDnsActionSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { enabled } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
try {
if (enabled) {
await addDNSEntry(sudoPassword);
} else {
await removeDNSEntry(sudoPassword);
}
upsertAgentBridgeState({ agent_id: id, dns_enabled: enabled });
return Response.json({ ok: true, dns_enabled: enabled });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,48 @@
/**
* GET /api/tools/agent-bridge/agents/[id]/mappings — list model mappings
* PUT /api/tools/agent-bridge/agents/[id]/mappings — replace all mappings
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { AgentBridgeMappingPutSchema } from "@/shared/schemas/agentBridge";
import { getMappingsForAgent, setMappings } from "@/lib/db/agentBridgeMappings";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
try {
const mappings = getMappingsForAgent(params.id);
return Response.json({ mappings });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function PUT(request: Request, { params }: Params): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeMappingPutSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
setMappings(params.id, parsed.data.mappings);
const mappings = getMappingsForAgent(params.id);
return Response.json({ ok: true, mappings });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,63 @@
/**
* GET /api/tools/agent-bridge/agents/[id] — agent detail
* PATCH /api/tools/agent-bridge/agents/[id] — update setup_completed flag
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { z } from "zod";
import { resolveTarget } from "@/mitm/targets/index";
import { detectAgent } from "@/mitm/detection/index";
import { getAgentBridgeState, upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import type { AgentId } from "@/mitm/types";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const PatchSchema = z.object({
setup_completed: z.boolean(),
});
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
try {
const { id } = params;
const target = resolveTarget(id) ?? null;
if (!target) {
return createErrorResponse({ status: 404, message: `Agent not found: ${id}` });
}
const detection = detectAgent(id as AgentId);
const state = getAgentBridgeState(id) ?? null;
return Response.json({ agent: target, detection, state });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
const { id } = params;
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
upsertAgentBridgeState({ agent_id: id, setup_completed: parsed.data.setup_completed });
const state = getAgentBridgeState(id);
return Response.json({ ok: true, state });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,25 @@
/**
* GET /api/tools/agent-bridge/agents
* Returns the full list of registered MITM targets mapped to a stable UI shape.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { ALL_TARGETS } from "@/mitm/targets/index";
import { detectAgent } from "@/mitm/detection/index";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const agents = ALL_TARGETS.map((t) => ({
id: t.id,
name: t.name,
hosts: t.hosts,
viability: t.viability ?? "supported",
state: detectAgent(t.id),
}));
return Response.json({ agents });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,71 @@
/**
* GET /api/tools/agent-bridge/bypass — list all patterns (default + user)
* POST /api/tools/agent-bridge/bypass — replace user patterns
* DELETE /api/tools/agent-bridge/bypass?pattern=X — remove a single user pattern
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { AgentBridgeBypassUpsertSchema } from "@/shared/schemas/agentBridge";
import {
getAllBypassPatterns,
replaceUserBypassPatterns,
getUserBypassPatterns,
} from "@/lib/db/agentBridgeBypass";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const patterns = getAllBypassPatterns();
return Response.json({ patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeBypassUpsertSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
replaceUserBypassPatterns(parsed.data.patterns);
const patterns = getAllBypassPatterns();
return Response.json({ ok: true, patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function DELETE(request: Request): Promise<Response> {
const url = new URL(request.url);
const pattern = url.searchParams.get("pattern");
if (!pattern) {
return createErrorResponse({ status: 400, message: "Missing query param: pattern" });
}
try {
const existing = getUserBypassPatterns();
const updated = existing.filter((p) => p !== pattern);
replaceUserBypassPatterns(updated);
const patterns = getAllBypassPatterns();
return Response.json({ ok: true, patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,34 @@
/**
* GET /api/tools/agent-bridge/cert/download
* Streams the PEM certificate file.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { resolveMitmDataDir } from "@/mitm/dataDir";
import path from "path";
import fs from "fs";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
const crtPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(crtPath)) {
return createErrorResponse({
status: 404,
message: "Certificate not found. Generate one first via POST /api/tools/agent-bridge/cert/regenerate",
});
}
try {
const pem = fs.readFileSync(crtPath);
return new Response(pem, {
status: 200,
headers: {
"Content-Type": "application/x-pem-file",
"Content-Disposition": 'attachment; filename="omniroute-mitm.crt"',
"Content-Length": String(pem.length),
},
});
} catch {
return createErrorResponse({ status: 500, message: "Failed to read certificate file" });
}
}

View File

@@ -0,0 +1,22 @@
/**
* POST /api/tools/agent-bridge/cert/regenerate
* Regenerates the MITM self-signed certificate. Overwrites the existing one.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { generateCert } from "@/mitm/cert/generate";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function POST(): Promise<Response> {
try {
// generateCert checks for existing files — force-regenerate by deleting first
// is not in scope; the function is idempotent (returns existing paths). If a
// caller needs a fresh cert they must delete the old one manually. We expose
// whatever generateCert decides.
const result = await generateCert();
return Response.json({ ok: true, certPath: result.cert, keyPath: result.key });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,50 @@
/**
* GET /api/tools/agent-bridge/cert — cert status
* POST /api/tools/agent-bridge/cert — trust (install) the cert
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { getCachedPassword } from "@/mitm/manager";
import path from "path";
import fs from "fs";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
function certPath(): string {
return path.join(resolveMitmDataDir(), "mitm", "server.crt");
}
export async function GET(): Promise<Response> {
try {
const crtPath = certPath();
const exists = fs.existsSync(crtPath);
const trusted = exists ? await checkCertInstalled(crtPath) : false;
return Response.json({ exists, trusted, path: exists ? crtPath : null });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
const raw = await request.json().catch(() => ({})) as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
try {
const crtPath = certPath();
if (!fs.existsSync(crtPath)) {
return createErrorResponse({
status: 404,
message: "Certificate not found. Generate one first.",
});
}
await installCert(sudoPassword, crtPath);
const trusted = await checkCertInstalled(crtPath);
return Response.json({ ok: true, trusted });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,81 @@
/**
* POST /api/tools/agent-bridge/server
* Start / stop / restart MITM server; trust cert; regenerate cert.
* LOCAL_ONLY + SPAWN_CAPABLE: registered in routeGuard.ts
*
* Body: AgentBridgeServerActionSchema
*/
import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge";
import { startMitm, stopMitm, getMitmStatus, setCachedPassword, getCachedPassword } from "@/mitm/manager";
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
import { generateCert } from "@/mitm/cert/generate";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import path from "path";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeServerActionSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { action } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword = typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey : (process.env.ROUTER_API_KEY ?? "");
try {
if (action === "start") {
if (sudoPassword) setCachedPassword(sudoPassword);
const result = await startMitm(apiKey, sudoPassword);
return Response.json({ ok: true, ...result });
}
if (action === "stop") {
const pwd = sudoPassword || getCachedPassword() || "";
const result = await stopMitm(pwd);
return Response.json({ ok: true, ...result });
}
if (action === "restart") {
const pwd = sudoPassword || getCachedPassword() || "";
const status = await getMitmStatus();
if (status.running) {
await stopMitm(pwd);
}
if (sudoPassword) setCachedPassword(sudoPassword);
const result = await startMitm(apiKey, sudoPassword || pwd);
return Response.json({ ok: true, ...result });
}
if (action === "trust-cert") {
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
const pwd = sudoPassword || getCachedPassword() || "";
await installCert(pwd, certPath);
const trusted = await checkCertInstalled(certPath);
return Response.json({ ok: true, trusted });
}
if (action === "regenerate-cert") {
const result = await generateCert();
return Response.json({ ok: true, certPath: result.cert });
}
return createErrorResponse({ status: 400, message: "Unknown action" });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,18 @@
/**
* GET /api/tools/agent-bridge/state
* Returns global MITM server status + per-agent detection/status.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { getMitmStatus, getAllAgentsStatus } from "@/mitm/manager";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const [server, agents] = await Promise.all([getMitmStatus(), getAllAgentsStatus()]);
return Response.json({ server, agents });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,80 @@
/**
* GET /api/tools/agent-bridge/upstream-ca — returns current upstream CA path
* POST /api/tools/agent-bridge/upstream-ca — validates + persists a new path
* LOCAL_ONLY: registered in routeGuard.ts
*
* Persistence: <dataDir>/mitm/upstream-ca.path (one-line text file)
* At runtime, calling configureUpstreamCa() with the stored path activates it.
*/
import { AgentBridgeUpstreamCaPostSchema } from "@/shared/schemas/agentBridge";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import path from "path";
import fs from "fs";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const CA_PATH_FILE = path.join(resolveMitmDataDir(), "mitm", "upstream-ca.path");
function readStoredCaPath(): string | null {
try {
if (!fs.existsSync(CA_PATH_FILE)) return null;
const raw = fs.readFileSync(CA_PATH_FILE, "utf8").trim();
return raw || null;
} catch {
return null;
}
}
function writeStoredCaPath(caPath: string): void {
const dir = path.dirname(CA_PATH_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CA_PATH_FILE, caPath + "\n");
}
export async function GET(): Promise<Response> {
try {
const stored = readStoredCaPath();
// Prefer env var; file is secondary
const active = process.env.AGENTBRIDGE_UPSTREAM_CA_CERT || stored || null;
return Response.json({ path: active });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeUpstreamCaPostSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { path: caPath } = parsed.data;
// Validate the file actually exists (plan 11 §4.7)
if (!fs.existsSync(caPath)) {
return createErrorResponse({
status: 400,
message: `Upstream CA file not found: ${caPath}`,
});
}
try {
writeStoredCaPath(caPath);
return Response.json({ ok: true, path: caPath });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -31,6 +31,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/services/", // T-10: embedded service lifecycle (spawn child processes)
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
];
/**
@@ -51,6 +52,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/cli-tools/runtime/",
"/api/services/", // T-10: can run npm install + spawn node processes
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
];
/**