From 19c4ff9bb021b420e69440e8d90e21628d768aec Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 00:24:38 -0300 Subject: [PATCH] feat(api): agent-bridge state + server + agents + cert + bypass + upstream-ca routes (F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../agent-bridge/agents/[id]/detect/route.ts | 39 +++++++++ .../agent-bridge/agents/[id]/dns/route.ts | 55 +++++++++++++ .../agents/[id]/mappings/route.ts | 48 +++++++++++ .../tools/agent-bridge/agents/[id]/route.ts | 63 +++++++++++++++ .../api/tools/agent-bridge/agents/route.ts | 25 ++++++ .../api/tools/agent-bridge/bypass/route.ts | 71 ++++++++++++++++ .../tools/agent-bridge/cert/download/route.ts | 34 ++++++++ .../agent-bridge/cert/regenerate/route.ts | 22 +++++ src/app/api/tools/agent-bridge/cert/route.ts | 50 ++++++++++++ .../api/tools/agent-bridge/server/route.ts | 81 +++++++++++++++++++ src/app/api/tools/agent-bridge/state/route.ts | 18 +++++ .../tools/agent-bridge/upstream-ca/route.ts | 80 ++++++++++++++++++ src/server/authz/routeGuard.ts | 2 + 13 files changed, 588 insertions(+) create mode 100644 src/app/api/tools/agent-bridge/agents/[id]/detect/route.ts create mode 100644 src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts create mode 100644 src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts create mode 100644 src/app/api/tools/agent-bridge/agents/[id]/route.ts create mode 100644 src/app/api/tools/agent-bridge/agents/route.ts create mode 100644 src/app/api/tools/agent-bridge/bypass/route.ts create mode 100644 src/app/api/tools/agent-bridge/cert/download/route.ts create mode 100644 src/app/api/tools/agent-bridge/cert/regenerate/route.ts create mode 100644 src/app/api/tools/agent-bridge/cert/route.ts create mode 100644 src/app/api/tools/agent-bridge/server/route.ts create mode 100644 src/app/api/tools/agent-bridge/state/route.ts create mode 100644 src/app/api/tools/agent-bridge/upstream-ca/route.ts diff --git a/src/app/api/tools/agent-bridge/agents/[id]/detect/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/detect/route.ts new file mode 100644 index 0000000000..32331ff142 --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/[id]/detect/route.ts @@ -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([ + "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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts new file mode 100644 index 0000000000..2aec5c7712 --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts @@ -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 { + 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; + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts new file mode 100644 index 0000000000..4ca2241681 --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/route.ts new file mode 100644 index 0000000000..c3366ed78a --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/[id]/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/agents/route.ts b/src/app/api/tools/agent-bridge/agents/route.ts new file mode 100644 index 0000000000..2916ac604d --- /dev/null +++ b/src/app/api/tools/agent-bridge/agents/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/bypass/route.ts b/src/app/api/tools/agent-bridge/bypass/route.ts new file mode 100644 index 0000000000..4fea1e66e5 --- /dev/null +++ b/src/app/api/tools/agent-bridge/bypass/route.ts @@ -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 { + 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 { + 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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/cert/download/route.ts b/src/app/api/tools/agent-bridge/cert/download/route.ts new file mode 100644 index 0000000000..9f0b40ce9d --- /dev/null +++ b/src/app/api/tools/agent-bridge/cert/download/route.ts @@ -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 { + 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" }); + } +} diff --git a/src/app/api/tools/agent-bridge/cert/regenerate/route.ts b/src/app/api/tools/agent-bridge/cert/regenerate/route.ts new file mode 100644 index 0000000000..2459275e71 --- /dev/null +++ b/src/app/api/tools/agent-bridge/cert/regenerate/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/cert/route.ts b/src/app/api/tools/agent-bridge/cert/route.ts new file mode 100644 index 0000000000..c5b894a85a --- /dev/null +++ b/src/app/api/tools/agent-bridge/cert/route.ts @@ -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 { + 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 { + const raw = await request.json().catch(() => ({})) as Record; + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/server/route.ts b/src/app/api/tools/agent-bridge/server/route.ts new file mode 100644 index 0000000000..d21b90ba6a --- /dev/null +++ b/src/app/api/tools/agent-bridge/server/route.ts @@ -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 { + 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; + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/state/route.ts b/src/app/api/tools/agent-bridge/state/route.ts new file mode 100644 index 0000000000..8a48d0c5f4 --- /dev/null +++ b/src/app/api/tools/agent-bridge/state/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/tools/agent-bridge/upstream-ca/route.ts b/src/app/api/tools/agent-bridge/upstream-ca/route.ts new file mode 100644 index 0000000000..ab7cedc9a5 --- /dev/null +++ b/src/app/api/tools/agent-bridge/upstream-ca/route.ts @@ -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: /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 { + 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 { + 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 }); + } +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index f077f274ac..6f49a3fe49 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -31,6 +31,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/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 = [ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/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) ]; /**