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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-27 13:31:40 -03:00
committed by GitHub
parent 898a1cb62d
commit 422b7bd3cf
8 changed files with 286 additions and 16 deletions

View File

@@ -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://<lan-host>:*` 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)

View File

@@ -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<string | null>(null);
const [certGuide, setCertGuide] = useState<CertManualGuide | null>(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({
</div>
)}
{/* Manual cert-install guide (container / headless fallback) */}
{certGuide && (
<div
role="status"
className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"
>
<div className="flex items-center gap-2 font-medium">
<span className="material-symbols-outlined text-[16px]">info</span>
{t("certManualTitle") ||
"Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run — trust the CA manually:"}
<button
type="button"
onClick={() => setCertGuide(null)}
className="ml-auto text-amber-600 hover:text-amber-500"
aria-label="Dismiss"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
<ol className="mt-2 list-decimal pl-6 space-y-1">
{certGuide.steps.map((step, i) => (
<li key={i} className="font-mono text-xs break-all">
{step}
</li>
))}
</ol>
<a
href={certGuide.downloadUrl}
download
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium underline hover:no-underline"
>
<span className="material-symbols-outlined text-[14px]">download</span>
{t("downloadCert") || "Download Cert"}
</a>
</div>
)}
{/* Empty state: no providers */}
{!hasProviders ? (
<EmptyStateNoProviders />

View File

@@ -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<Response> {
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 });

View File

@@ -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<Response> {
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") {

View File

@@ -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…",

View File

@@ -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<CertInstallResult> {
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<void> {
try {
await execFileWithPassword(

View File

@@ -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,
};
}

View File

@@ -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");
});