From 422b7bd3cf42142ab221d92c83801bc7e1166be4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:31:40 -0300 Subject: [PATCH] feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178) Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result. --- CHANGELOG.md | 4 + .../agent-bridge/AgentBridgePageClient.tsx | 63 ++++++++++- src/app/api/tools/agent-bridge/cert/route.ts | 23 +++- .../api/tools/agent-bridge/server/route.ts | 23 +++- src/i18n/messages/en.json | 1 + src/mitm/cert/install.ts | 100 ++++++++++++++++++ src/mitm/manager.ts | 24 ++++- ...-bridge-cert-install-fallback-4546.test.ts | 64 +++++++++++ 8 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 tests/unit/agent-bridge-cert-install-fallback-4546.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d944461c..1cb221ada7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development β€” bullets added per PR; finalized at release._ +### ✨ New Features + +- **feat(agent-bridge): graceful cert-install fallback for containers / headless** β€” when the MITM root CA can't be installed into the system trust store automatically (Docker / headless / no sudo / read-only trust store), the Agent Bridge no longer hard-fails on start with a generic "Certificate install failed". It now starts in skip mode and the dashboard surfaces a platform-specific **manual-install guide** (plus a CA download link) so the operator can trust the certificate by hand. The trust-cert endpoints return a structured `{ skippable, manualGuide }` response (HTTP 200) for environment failures instead of a 500; an explicit user cancellation is still reported distinctly. ([#4546](https://github.com/diegosouzapw/OmniRoute/issues/4546) β€” thanks @phuchptty) + ### πŸ”§ Bug Fixes - **fix(api): LAN/Tailscale dashboard access β€” `ws:` CSP scheme, GET-exempt version route, surface combo field errors** β€” three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) β€” a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message β€” `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) β€” thanks @KooshaPari for the diagnosis and original PR #5084) diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx index 074d57a237..e1a583ea69 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx @@ -23,6 +23,14 @@ export interface AgentStateEntry { last_error: string | null; } +/** Manual cert-install guide returned when auto-trust isn't possible (containers). */ +export interface CertManualGuide { + platform: string; + certPath: string; + downloadUrl: string; + steps: string[]; +} + export interface AgentBridgeServerState { running: boolean; port: number; @@ -62,6 +70,7 @@ export default function AgentBridgePageClient({ const t = useTranslations("agentBridge"); const { data, refresh } = useAgentBridgeState({ initialData }); const [actionError, setActionError] = useState(null); + const [certGuide, setCertGuide] = useState(null); // ── Server actions ──────────────────────────────────────────────────────── @@ -74,11 +83,20 @@ export default function AgentBridgePageClient({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), }); + const payload = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + skippable?: boolean; + manualGuide?: CertManualGuide; + }; 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}`); + throw new Error(payload.error?.message ?? `HTTP ${res.status}`); + } + // Cert couldn't be auto-installed (container / headless): not an error β€” + // surface the manual-install guide instead of blocking. (#4546) + if (payload.skippable && payload.manualGuide) { + setCertGuide(payload.manualGuide); + } else if (action === "trust-cert") { + setCertGuide(null); } await refresh(); } catch (err) { @@ -188,6 +206,43 @@ export default function AgentBridgePageClient({ )} + {/* Manual cert-install guide (container / headless fallback) */} + {certGuide && ( +
+
+ info + {t("certManualTitle") || + "Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run β€” trust the CA manually:"} + +
+
    + {certGuide.steps.map((step, i) => ( +
  1. + {step} +
  2. + ))} +
+ + download + {t("downloadCert") || "Download Cert"} + +
+ )} + {/* Empty state: no providers */} {!hasProviders ? ( diff --git a/src/app/api/tools/agent-bridge/cert/route.ts b/src/app/api/tools/agent-bridge/cert/route.ts index a235026cd5..ab9499a620 100644 --- a/src/app/api/tools/agent-bridge/cert/route.ts +++ b/src/app/api/tools/agent-bridge/cert/route.ts @@ -4,7 +4,7 @@ * LOCAL_ONLY: registered in routeGuard.ts */ import { z } from "zod"; -import { installCert, uninstallCert, checkCertInstalled } from "@/mitm/cert/install"; +import { installCertResult, uninstallCert, checkCertInstalled } from "@/mitm/cert/install"; import { resolveMitmDataDir } from "@/mitm/dataDir"; import { getCachedPassword } from "@/mitm/manager"; import path from "path"; @@ -48,9 +48,24 @@ export async function POST(request: Request): Promise { message: "Certificate not found. Generate one first.", }); } - await installCert(sudoPassword, crtPath); - const trusted = await checkCertInstalled(crtPath); - return Response.json({ ok: true, trusted }); + const result = await installCertResult(sudoPassword, crtPath); + if (result.installed) { + const trusted = await checkCertInstalled(crtPath); + return Response.json({ ok: true, trusted }); + } + if (result.reason === "canceled") { + return createErrorResponse({ status: 409, message: "User canceled authorization" }); + } + // Environment failure (container / headless): not a 500 β€” surface the + // manual-install guide so the operator can trust the CA by hand. (#4546) + return Response.json({ + ok: false, + trusted: false, + skippable: true, + reason: result.reason, + message: sanitizeErrorMessage(result.message ?? "Certificate install failed"), + manualGuide: result.manualGuide, + }); } 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 index d21b90ba6a..671579beff 100644 --- a/src/app/api/tools/agent-bridge/server/route.ts +++ b/src/app/api/tools/agent-bridge/server/route.ts @@ -7,7 +7,7 @@ */ import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge"; import { startMitm, stopMitm, getMitmStatus, setCachedPassword, getCachedPassword } from "@/mitm/manager"; -import { installCert, checkCertInstalled } from "@/mitm/cert/install"; +import { installCertResult, checkCertInstalled } from "@/mitm/cert/install"; import { generateCert } from "@/mitm/cert/generate"; import { resolveMitmDataDir } from "@/mitm/dataDir"; import path from "path"; @@ -63,9 +63,24 @@ export async function POST(request: Request): Promise { 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 }); + const result = await installCertResult(pwd, certPath); + if (result.installed) { + const trusted = await checkCertInstalled(certPath); + return Response.json({ ok: true, trusted }); + } + if (result.reason === "canceled") { + return createErrorResponse({ status: 409, message: "User canceled authorization" }); + } + // Environment failure (container / headless): not an error β€” return the + // manual-install guide so the UI can let the operator trust the CA by hand. + return Response.json({ + ok: false, + trusted: false, + skippable: true, + reason: result.reason, + message: sanitizeErrorMessage(result.message ?? "Certificate install failed"), + manualGuide: result.manualGuide, + }); } if (action === "regenerate-cert") { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1fb6f3956f..d8cd7c401d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -8564,6 +8564,7 @@ "restartServer": "Restart", "trustCert": "Trust Cert", "downloadCert": "Download Cert", + "certManualTitle": "Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run β€” trust the CA manually:", "regenerateCert": "Regenerate Cert", "starting": "Starting…", "stopping": "Stopping…", diff --git a/src/mitm/cert/install.ts b/src/mitm/cert/install.ts index 782aac7866..7a8506ef38 100644 --- a/src/mitm/cert/install.ts +++ b/src/mitm/cert/install.ts @@ -180,6 +180,106 @@ export async function installCert(sudoPassword: string, certPath: string): Promi } } +// ── Graceful fallback for containers / headless environments (#4546) ────────── +// +// In a container the system trust store can't be written (no sudo / read-only +// store / no interactive auth), so installCert() throws and used to abort the +// whole Agent Bridge start. The helpers below let callers treat that as a +// recoverable "skip" with a manual-install guide, instead of a hard failure. + +const CERT_DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download"; + +/** Why an automatic cert install did not complete. */ +export type CertInstallReason = "canceled" | "environment"; + +/** Platform-specific steps the operator can run to trust the MITM root CA by hand. */ +export interface CertManualGuide { + platform: NodeJS.Platform; + certPath: string; + downloadUrl: string; + steps: string[]; +} + +/** Structured outcome of an attempted cert install (never throws for env failures). */ +export interface CertInstallResult { + installed: boolean; + skipped: boolean; + reason?: CertInstallReason; + /** Safe, already-sanitized message (no stack trace). */ + message?: string; + manualGuide?: CertManualGuide; +} + +/** + * Classify a cert-install failure message. Only an explicit user cancellation + * counts as "canceled"; every other failure (missing trust store, no sudo, + * read-only FS, container) is treated as an "environment" failure that the + * operator can resolve with a manual install. + */ +export function classifyCertInstallError(message: string): CertInstallReason { + return /cancel+ed/i.test(message) ? "canceled" : "environment"; +} + +/** + * Build the manual-install instructions for trusting the MITM root CA on the + * given platform. Pure + platform-overridable so it is fully unit-testable. + */ +export function buildCertManualGuide( + certPath: string, + platform: NodeJS.Platform = process.platform +): CertManualGuide { + let steps: string[]; + if (platform === "win32") { + steps = [ + `certutil -addstore -f Root "${certPath}"`, + "Or import it via certmgr.msc β†’ Trusted Root Certification Authorities β†’ Certificates β†’ Import.", + ]; + } else if (platform === "darwin") { + steps = [ + `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`, + ]; + } else { + // Linux β€” match the detected distro's anchor dir + refresh command. + const config = getLinuxCertConfig(); + steps = [ + `sudo cp "${certPath}" ${config.dir}/${LINUX_CERT_NAME}`, + `sudo ${config.cmd}`, + `Container-friendly per-tool trust (no root needed): set NODE_EXTRA_CA_CERTS="${certPath}" (Node) or REQUESTS_CA_BUNDLE="${certPath}" (Python), or import "${certPath}" into your client's trust store.`, + ]; + } + return { platform, certPath, downloadUrl: CERT_DOWNLOAD_URL, steps }; +} + +/** + * Attempt to install the cert, returning a structured result instead of + * throwing on environment failures. A user-canceled authorization is reported + * with reason "canceled" (not skipped); any other failure is reported as a + * skippable "environment" failure carrying a manual-install guide so the bridge + * can still start and the operator can trust the CA by hand. + */ +export async function installCertResult( + sudoPassword: string, + certPath: string +): Promise { + try { + await installCert(sudoPassword, certPath); + return { installed: true, skipped: false }; + } catch (error) { + const message = getErrorMessage(error); + const reason = classifyCertInstallError(message); + if (reason === "canceled") { + return { installed: false, skipped: false, reason, message }; + } + return { + installed: false, + skipped: true, + reason, + message, + manualGuide: buildCertManualGuide(certPath), + }; + } +} + async function installCertMac(sudoPassword: string, certPath: string): Promise { try { await execFileWithPassword( diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index a68a84b4dd..fde526a2f7 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -4,7 +4,7 @@ import fs from "fs"; import { resolveMitmDataDir } from "./dataDir.ts"; import { addDNSEntry, addDNSEntries, removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { generateCert } from "./cert/generate.ts"; -import { installCert, uninstallCert } from "./cert/install.ts"; +import { installCertResult, uninstallCert } from "./cert/install.ts"; import { ALL_TARGETS } from "./targets/index.ts"; import { detectAgent } from "./detection/index.ts"; import type { AgentId, DetectionResult, MitmTarget } from "./types.ts"; @@ -397,7 +397,7 @@ export async function startMitm( apiKey: string, sudoPassword: string, options: { port?: number } = {} -): Promise<{ running: true; pid: number | null }> { +): Promise<{ running: true; pid: number | null; certTrusted: boolean }> { // Check if already running if (serverProcess && !serverProcess.killed) { throw new Error("MITM proxy is already running"); @@ -446,8 +446,23 @@ export async function startMitm( await generateCert(); } - // 2. Install certificate to system keychain - await installCert(sudoPassword, certPath); + // 2. Install certificate to system keychain. A failure here must NOT abort the + // bridge: in containers/headless the system trust store can't be written, + // so we start in "untrusted" mode and let the operator trust the CA by hand + // (mirrors the best-effort "continuing" pattern used for DNS below). (#4546) + let certTrusted = false; + try { + const certResult = await installCertResult(sudoPassword, certPath); + certTrusted = certResult.installed; + if (!certResult.installed) { + log.warn( + { reason: certResult.reason }, + "MITM cert not auto-trusted; bridge starting in skip mode (manual trust required)" + ); + } + } catch (err) { + log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)"); + } // 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true + // all custom hosts with enabled=true. @@ -600,6 +615,7 @@ export async function startMitm( return { running: true, pid: serverPid, + certTrusted, }; } diff --git a/tests/unit/agent-bridge-cert-install-fallback-4546.test.ts b/tests/unit/agent-bridge-cert-install-fallback-4546.test.ts new file mode 100644 index 0000000000..33c9746b4e --- /dev/null +++ b/tests/unit/agent-bridge-cert-install-fallback-4546.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4546 β€” In containers/headless, the system trust store can't be written +// (no sudo / read-only store / no interactive auth), so the cert install +// throws and used to abort the whole Agent Bridge start. These tests pin the +// graceful-fallback contract: a structured result that distinguishes a +// user-canceled auth from an environment failure, plus a platform-specific +// manual-install guide so the operator can trust the MITM root CA themselves. + +const { classifyCertInstallError, buildCertManualGuide, installCertResult } = await import( + "../../src/mitm/cert/install.ts" +); + +const DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download"; + +test("classifyCertInstallError β†’ 'canceled' only when the message says canceled", () => { + assert.equal(classifyCertInstallError("User canceled authorization"), "canceled"); + assert.equal(classifyCertInstallError("Operation was canceled by the user"), "canceled"); +}); + +test("classifyCertInstallError β†’ 'environment' for trust-store / sudo failures", () => { + assert.equal(classifyCertInstallError("Certificate install failed"), "environment"); + assert.equal(classifyCertInstallError("sudo: no tty present and no askpass program specified"), "environment"); + assert.equal(classifyCertInstallError("Certificate file not found: /x/server.crt"), "environment"); +}); + +test("buildCertManualGuide(linux) β†’ update-ca-certificates steps + download url + cert path", () => { + const guide = buildCertManualGuide("/data/mitm/server.crt", "linux"); + assert.equal(guide.platform, "linux"); + assert.equal(guide.certPath, "/data/mitm/server.crt"); + assert.equal(guide.downloadUrl, DOWNLOAD_URL); + assert.ok(Array.isArray(guide.steps) && guide.steps.length > 0); + const joined = guide.steps.join("\n"); + assert.ok(joined.includes("update-ca-"), "should mention the distro CA refresh command"); + assert.ok(joined.includes("/data/mitm/server.crt"), "should reference the cert path"); +}); + +test("buildCertManualGuide(darwin) β†’ security add-trusted-cert", () => { + const guide = buildCertManualGuide("/d/server.crt", "darwin"); + assert.equal(guide.platform, "darwin"); + assert.ok(guide.steps.join("\n").includes("add-trusted-cert")); +}); + +test("buildCertManualGuide(win32) β†’ certutil -addstore Root", () => { + const guide = buildCertManualGuide("C:/d/server.crt", "win32"); + assert.equal(guide.platform, "win32"); + assert.ok(guide.steps.join("\n").toLowerCase().includes("certutil")); +}); + +test("installCertResult β†’ environment skip (not a throw) when install is impossible", async () => { + // A non-existent cert path makes installCert() throw before any privileged + // command runs β€” deterministic, no sudo. The wrapper must convert that into a + // structured skippable result with a manual guide, never a thrown error. + const result = await installCertResult("", "/nonexistent/omniroute-4546-server.crt"); + assert.equal(result.installed, false); + assert.equal(result.skipped, true); + assert.equal(result.reason, "environment"); + assert.ok(result.manualGuide, "environment skip must carry a manual guide"); + assert.equal(result.manualGuide?.downloadUrl, DOWNLOAD_URL); + // The message must be a safe string (no stack trace leaked). + assert.equal(typeof result.message, "string"); + assert.ok(!String(result.message).includes("\n at "), "must not leak a stack trace"); +});