feat(mitm): root-CA + per-host leaf certs for AgentBridge static server (#6684) (#7731)

Replace the AgentBridge static server's single self-signed leaf cert
(scoped only to the 4 antigravity hosts) with a persisted local root CA
+ per-SNI leaf certs, reusing the CA/leaf crypto already proven for the
TPROXY capture mode (tproxy/dynamicCert.ts). server.cjs switches from a
static key/cert to an SNICallback so every host in MITM_TOOL_HOSTS gets
a matching leaf, not just antigravity.

- src/mitm/cert/rootCa.ts: load-or-generate-once CA persistence
  (ca.key/ca.crt under <DATA_DIR>/mitm/), private key chmod 0o600.
- src/mitm/cert/migration.ts: pure migration gate — an already-trusted
  legacy leaf install stays on the old leaf until the operator opts in
  via MITM_ROOT_CA_ENABLED=true; a fresh install gets the CA model
  automatically. A CA that can sign a leaf for any host is materially
  more powerful than the old fixed-SAN leaf, so the switch is never
  silent for an already-trusted install.
- src/mitm/cert/install.ts: installCaCert() — thin wrapper over the
  existing cert-path-agnostic installCertResult(), same
  omniroute-mitm.crt trust-store slot the old leaf used (supersedes it,
  no dual-trust cleanup needed).
- src/mitm/manager.ts: wires the migration gate + CA load/install into
  the bridge-start sequence, passes the resolved MITM_CERT_MODE to the
  spawned server.cjs child so it can't drift from manager.ts's decision.
- src/mitm/server.cjs: async-bootstraps server creation behind the same
  MITM_CERT_MODE gate; default ("legacy") reproduces the exact prior
  synchronous behavior. The CJS/ESM boundary (server.cjs is spawned via
  plain `node`, no TS loader) is crossed via a new
  _internal/rootCaShim.cjs CJS twin of the CA/leaf crypto, matching the
  established pattern of the sibling _internal/*.cjs shims in this file.

Validated: 14 new unit tests (CA generate-once, 0o600 key perms, CA
basicConstraints, leaf issuance across every MITM_TOOL_HOSTS host, SAN
match, chain validation against the CA, leaf caching, migration-gate
branches) plus a manual live smoke test spawning server.cjs in both
legacy and root-ca mode (confirmed a real TLS handshake with SNI
api.githubcopilot.com returns a CA-issued leaf for that host).

Deferred to VPS live validation (OS-trust-store mutation is not
unit-testable): actual OS trust-store install of the CA cert via
installCaCert() on Linux/macOS/Windows.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 14:37:27 -03:00
committed by GitHub
parent 2ae40611b2
commit a2004060c5
12 changed files with 810 additions and 241 deletions

View File

@@ -0,0 +1 @@
- **feat(mitm):** the AgentBridge static MITM server (`server.cjs`) can now issue a per-host TLS leaf from a persisted local root CA (`src/mitm/cert/rootCa.ts`, reusing the CA/leaf crypto already proven for TPROXY in `tproxy/dynamicCert.ts`) instead of a single static self-signed leaf scoped only to the 4 antigravity hosts — a fresh install covers the full `MITM_TOOL_HOSTS` set automatically; an install that already trusted the old leaf keeps using it until the operator opts in via `MITM_ROOT_CA_ENABLED=true` (`src/mitm/cert/migration.ts`), so no existing install is silently upgraded to the more powerful any-host-signing CA trust model (#6684).

View File

@@ -84,6 +84,22 @@ The core MITM server runs as a Node.js CJS child process (to avoid rewriting the
`TARGET_HOSTS` is loaded from `DATA_DIR/mitm/targets.json` (written by `targets/index.ts` at boot), allowing dynamic updates without restarting the CJS server.
> **Root-CA model (#6684).** The per-SNI-cert-signed-by-a-CA description above
> is the persisted root-CA model added in #6684 (`src/mitm/cert/rootCa.ts` +
> `src/mitm/_internal/rootCaShim.cjs`, reusing the CA/leaf crypto already
> proven for TPROXY in `src/mitm/tproxy/dynamicCert.ts`) — it replaces the
> older single static self-signed leaf (`src/mitm/cert/generate.ts`, still
> scoped only to the antigravity hosts) that a bare `server.crt`/`server.key`
> pair on disk indicates. **Migration behavior**: a fresh install (no prior
> `server.crt`) gets the root-CA model automatically; an install that already
> trusted the old static leaf keeps using it until the operator sets
> `MITM_ROOT_CA_ENABLED=true` and restarts the bridge (`src/mitm/cert/migration.ts`
> is the pure decision function — a trusted MITM CA that can sign a leaf for
> **any** host is materially more powerful than the old fixed-SAN leaf, so the
> switch is never silent for an already-trusted install). The CA cert installs
> into the same `omniroute-mitm.crt` trust-store slot the old leaf used
> (`cert/install.ts::installCaCert`) — no dual-trust cleanup needed.
### 2.3 Handler base (`src/mitm/handlers/base.ts`)
All agent handlers extend `MitmHandlerBase`:

View File

@@ -118,9 +118,26 @@ The loader probes, in priority order:
## §4 The per-SNI dynamic CA and trust-store installer
The static AgentBridge MITM cert works only because AgentBridge DNS-spoofs a
**fixed** host set. TPROXY intercepts **arbitrary** hosts, so the listener must
present a valid leaf for whatever SNI the client requests.
> **#6684 update:** the AgentBridge static server (`src/mitm/server.cjs`) now
> shares this same CA/leaf architecture pattern instead of a single static
> self-signed leaf. It uses a **distinct** CA instance
> (`src/mitm/cert/rootCa.ts`, persisted at `<DATA_DIR>/mitm/ca.key`/`ca.crt`)
> and installs under the pre-existing `omniroute-mitm.crt` trust-store slot
> (superseding the old single leaf there — no dual-trust cleanup needed),
> kept fully separate from TPROXY's own `omniroute-tproxy-ca.crt` slot below.
> Fresh AgentBridge installs get the CA model automatically; an install that
> already trusted the old static leaf keeps using it until the operator opts
> in via `MITM_ROOT_CA_ENABLED=true` (see `src/mitm/cert/migration.ts`) — a
> trusted MITM CA that can sign a leaf for **any** host is materially more
> powerful than the old fixed-SAN leaf, so the switch is never silent for an
> already-trusted install.
Historically, the static AgentBridge MITM cert worked only because AgentBridge
DNS-spoofs a **fixed** host set (now unified with the model below). TPROXY
intercepts **arbitrary** hosts, so its listener must present a valid leaf for
whatever SNI the client requests — the same requirement AgentBridge now has
for the full `MITM_TOOL_HOSTS` set (9 tool entries) instead of just the 4
antigravity hosts.
### Dynamic CA (`src/mitm/tproxy/dynamicCert.ts`)

View File

@@ -0,0 +1,108 @@
/**
* CJS twin of the root-CA persistence + per-host leaf issuance used by
* `src/mitm/server.cjs` (#6684).
*
* This file exists for the same reason the sibling `_internal/*.cjs` shims
* do: `server.cjs` runs as a standalone CommonJS process (spawned via plain
* `node server.cjs`, no TS/ESM loader — see `src/mitm/manager.ts`'s
* `spawn(process.execPath, [MITM_SERVER_PATH], ...)`), so it cannot
* `import()` the ESM/TS sources directly. The CA-generation and leaf-signing
* parameters below are copied byte-for-byte from the proven, already-tested
* TS implementation in `src/mitm/tproxy/dynamicCert.ts`
* (`generateMitmCa`/`issueLeafCert`) — do not let this drift from that file;
* any change to the signing options there should be mirrored here.
*
* Persistence layout mirrors `src/mitm/cert/rootCa.ts` exactly (same
* `ca.key`/`ca.crt` file names under `<DATA_DIR>/mitm/`), so a CA generated
* by one is loaded by the other without conversion.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const tls = require("tls");
const CA_KEY_FILE = "ca.key";
const CA_CERT_FILE = "ca.crt";
async function generateMitmCa(name) {
const { default: selfsigned } = await import("selfsigned");
const notAfter = new Date();
notAfter.setFullYear(notAfter.getFullYear() + 10);
const pems = await selfsigned.generate([{ name: "commonName", value: name || "OmniRoute MITM CA" }], {
keySize: 2048,
algorithm: "sha256",
notAfterDate: notAfter,
extensions: [
{ name: "basicConstraints", cA: true, critical: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
],
});
return { key: pems.private, cert: pems.cert };
}
async function issueLeafCert(hostname, ca) {
const { default: selfsigned } = await import("selfsigned");
const notAfter = new Date();
notAfter.setFullYear(notAfter.getFullYear() + 1);
const pems = await selfsigned.generate([{ name: "commonName", value: hostname }], {
keySize: 2048,
algorithm: "sha256",
notAfterDate: notAfter,
extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }],
ca: { key: ca.key, cert: ca.cert },
});
return { key: pems.private, cert: `${pems.cert.trim()}\n${ca.cert.trim()}\n` };
}
/** Load the persisted CA from `certDir`, generating + persisting one on first run. */
async function loadOrCreateMitmCa(certDir) {
const keyPath = path.join(certDir, CA_KEY_FILE);
const certPath = path.join(certDir, CA_CERT_FILE);
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
return {
key: fs.readFileSync(keyPath, "utf-8"),
cert: fs.readFileSync(certPath, "utf-8"),
keyPath,
certPath,
};
}
const ca = await generateMitmCa("OmniRoute MITM CA");
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true });
fs.writeFileSync(keyPath, ca.key);
fs.writeFileSync(certPath, ca.cert);
fs.chmodSync(keyPath, 0o600);
return { key: ca.key, cert: ca.cert, keyPath, certPath };
}
/** Lazily issue + cache one `tls.SecureContext` per SNI host, signed by `ca`. */
class DynamicCertStore {
constructor(ca) {
this.ca = ca;
this.contexts = new Map();
}
async getSecureContext(hostname) {
const cached = this.contexts.get(hostname);
if (cached) return cached;
const leaf = await issueLeafCert(hostname, this.ca);
const ctx = tls.createSecureContext({ key: leaf.key, cert: leaf.cert });
this.contexts.set(hostname, ctx);
return ctx;
}
createSNICallback() {
return (servername, cb) => {
this.getSecureContext(servername)
.then((ctx) => cb(null, ctx))
.catch((err) => cb(err instanceof Error ? err : new Error(String(err))));
};
}
}
module.exports = { loadOrCreateMitmCa, issueLeafCert, DynamicCertStore };

View File

@@ -314,6 +314,24 @@ export async function installCertResult(
}
}
/**
* Install the persisted MITM root CA cert (`cert/rootCa.ts`) into the OS
* trust store. Named wrapper over {@link installCertResult} for call-site
* clarity — the underlying platform installers
* (`installCertLinux`/`installCertMac`/`installCertWindows`) are already
* cert-path-agnostic and keep writing to the same `omniroute-mitm.crt`
* trust-store slot the old single-leaf install used, so the CA cert simply
* supersedes the old leaf under that slot; no new slot, no dual-trust
* cleanup needed. Distinct from TPROXY's own `omniroute-tproxy-ca.crt` slot
* (`src/mitm/tproxy/caTrust.ts`), which this feature does not touch. #6684
*/
export async function installCaCert(
sudoPassword: string,
caCertPath: string
): Promise<CertInstallResult> {
return installCertResult(sudoPassword, caCertPath);
}
async function installCertMac(sudoPassword: string, certPath: string): Promise<void> {
try {
await execFileWithPassword(

View File

@@ -0,0 +1,43 @@
import path from "path";
import fs from "fs";
// #6684: migration gate between the legacy single self-signed leaf
// (`cert/generate.ts` → `server.crt`/`server.key`) and the new persisted
// root-CA + per-host-leaf model (`cert/rootCa.ts` → `ca.crt`/`ca.key`).
//
// A trusted MITM CA that can sign a certificate for ANY host is materially
// more powerful than today's fixed-SAN leaf, so switching an already-trusted
// install to the CA model must be an explicit, opt-in transition — never a
// silent upgrade that could re-trigger (or skip) an OS trust prompt a user
// isn't expecting. This module is a pure decision function so the gate is
// unit-testable without touching the filesystem beyond the passed-in dir.
export type CertMigrationDecision = "use-legacy-leaf" | "use-root-ca";
/**
* Decide which cert model a run of the AgentBridge static server should use.
*
* - A pre-existing legacy leaf (`server.crt`/`server.key`) with no CA pair
* yet present means an already-trusted install: keep serving the legacy
* leaf this run (unchanged behavior) unless the operator has explicitly
* opted in via `rootCaEnabled`.
* - Anything else (fresh install, or explicit opt-in) proceeds to the CA
* model — `loadOrCreateMitmCa()` will generate-once or load the persisted
* pair as appropriate.
*/
export function decideCertMigration(
certDir: string,
rootCaEnabled: boolean
): CertMigrationDecision {
if (rootCaEnabled) return "use-root-ca";
const hasLegacyLeaf =
fs.existsSync(path.join(certDir, "server.crt")) &&
fs.existsSync(path.join(certDir, "server.key"));
const hasCaPair =
fs.existsSync(path.join(certDir, "ca.crt")) && fs.existsSync(path.join(certDir, "ca.key"));
if (hasLegacyLeaf && !hasCaPair) return "use-legacy-leaf";
return "use-root-ca";
}

73
src/mitm/cert/rootCa.ts Normal file
View File

@@ -0,0 +1,73 @@
import path from "path";
import fs from "fs";
import { resolveMitmDataDir } from "../dataDir.ts";
import { generateMitmCa, type CaPair } from "../tproxy/dynamicCert.ts";
// #6684: persisted local root CA for the AgentBridge static server, so it can
// issue a per-host leaf for every host in `MITM_TOOL_HOSTS` (not just the 4
// antigravity hosts the legacy single self-signed leaf covers) without
// re-prompting the OS trust store on every restart. Reuses the exact
// `generateMitmCa()` crypto already proven for the TPROXY capture mode
// (`../tproxy/dynamicCert.ts`) — this module only adds the disk-persistence
// layer (load-if-present, generate-once-otherwise, restrictive file mode on
// the private key).
export interface MitmCaPair extends CaPair {
keyPath: string;
certPath: string;
}
const CA_KEY_FILE = "ca.key";
const CA_CERT_FILE = "ca.crt";
/** Directory the CA key/cert pair (and legacy leaf) live under. */
export function resolveMitmCertDir(): string {
return path.join(resolveMitmDataDir(), "mitm");
}
function caPaths(certDir: string): { keyPath: string; certPath: string } {
return {
keyPath: path.join(certDir, CA_KEY_FILE),
certPath: path.join(certDir, CA_CERT_FILE),
};
}
/**
* Load the persisted MITM root CA from disk if both `ca.key`/`ca.crt` exist;
* otherwise generate a fresh CA (via `generateMitmCa()`) and persist it. The
* private key file is chmod'd `0o600` (owner read/write only) immediately
* after writing — it must never be group/world-readable.
*
* Idempotent across restarts: once written, a later call returns the exact
* same key/cert bytes without regenerating, so the OS trust-store install
* only has to happen once per machine.
*/
export async function loadOrCreateMitmCa(
certDir: string = resolveMitmCertDir()
): Promise<MitmCaPair> {
const { keyPath, certPath } = caPaths(certDir);
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
return {
key: fs.readFileSync(keyPath, "utf-8"),
cert: fs.readFileSync(certPath, "utf-8"),
keyPath,
certPath,
};
}
const ca = await generateMitmCa();
if (!fs.existsSync(certDir)) {
fs.mkdirSync(certDir, { recursive: true });
}
fs.writeFileSync(keyPath, ca.key);
fs.writeFileSync(certPath, ca.cert);
// Owner-only read/write — the CA private key must never be group/world-
// readable (it can sign a trusted leaf for any host). No-op on Windows,
// which does not honor POSIX chmod bits.
fs.chmodSync(keyPath, 0o600);
return { key: ca.key, cert: ca.cert, keyPath, certPath };
}

View File

@@ -5,7 +5,9 @@ import { resolveMitmDataDir } from "./dataDir.ts";
import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts";
import { provisionDnsEntries } from "./dns/provision.ts";
import { generateCert } from "./cert/generate.ts";
import { installCertResult } from "./cert/install.ts";
import { installCertResult, installCaCert } from "./cert/install.ts";
import { loadOrCreateMitmCa, resolveMitmCertDir } from "./cert/rootCa.ts";
import { decideCertMigration } from "./cert/migration.ts";
import { ALL_TARGETS } from "./targets/index.ts";
import { detectAgent } from "./detection/index.ts";
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
@@ -478,14 +480,36 @@ async function startMitmInternal(
);
}
// 1. Generate SSL certificate if not exists
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(certPath)) {
log.info("Generating SSL certificate...");
// 1. Generate (or load the persisted) certificate material. #6684: a
// pre-existing trusted legacy leaf keeps this run on the legacy
// self-signed path (no silent trust-model upgrade — a MITM root CA that
// can sign a leaf for ANY host is materially more powerful than the old
// fixed-SAN leaf); fresh installs, and installs with `MITM_ROOT_CA_ENABLED
// =true`, get the persisted root-CA + per-host-leaf model instead
// (`cert/rootCa.ts`, reusing the CA/leaf crypto proven for TPROXY in
// `tproxy/dynamicCert.ts`).
const certDir = resolveMitmCertDir();
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
const migrationDecision = decideCertMigration(certDir, rootCaEnabled);
let certPath: string;
if (migrationDecision === "use-legacy-leaf") {
certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(certPath)) {
log.info("Generating SSL certificate...");
try {
await generateCert();
} catch (err) {
log.error({ err }, "Failed to generate SSL certificate");
throw err;
}
}
} else {
log.info("Loading (or generating) persisted MITM root CA...");
try {
await generateCert();
const ca = await loadOrCreateMitmCa(certDir);
certPath = ca.certPath;
} catch (err) {
log.error({ err }, "Failed to generate SSL certificate");
log.error({ err }, "Failed to load/generate MITM root CA");
throw err;
}
}
@@ -496,7 +520,10 @@ async function startMitmInternal(
// (mirrors the best-effort "continuing" pattern used for DNS below). (#4546)
let certTrusted = false;
try {
const certResult = await installCertResult(sudoPassword, certPath);
const certResult =
migrationDecision === "use-root-ca"
? await installCaCert(sudoPassword, certPath)
: await installCertResult(sudoPassword, certPath);
certTrusted = certResult.installed;
if (!certResult.installed) {
log.warn(
@@ -549,6 +576,10 @@ async function startMitmInternal(
ROUTER_API_KEY: apiKey,
MITM_LOCAL_PORT: String(port),
INSPECTOR_INTERNAL_INGEST_TOKEN: ingestToken,
// #6684: tell the spawned server.cjs which cert model this run resolved
// to (Step 1 above) so its own gate can't drift from manager.ts's
// migration decision.
MITM_CERT_MODE: migrationDecision === "use-root-ca" ? "root-ca" : "legacy",
NODE_ENV: "production",
},
detached: false,

View File

@@ -235,10 +235,41 @@ function writeStats() {
}
}
const sslOptions = {
key: fs.readFileSync(path.join(certDir, "server.key")),
cert: fs.readFileSync(path.join(certDir, "server.crt")),
};
// #6684: root-CA + per-host leaf certs, gated by MITM_CERT_MODE (set by
// manager.ts from its own migration-gate decision, `cert/migration.ts`) so
// server.cjs never drifts from what manager.ts installed/trusted. Default
// (unset/"legacy") reproduces the exact prior behavior — a single static
// self-signed leaf read synchronously at module scope — so existing installs
// are never silently upgraded to the new trust model.
const CERT_MODE = process.env.MITM_CERT_MODE === "root-ca" ? "root-ca" : "legacy";
function loadLegacySslOptions() {
return {
key: fs.readFileSync(path.join(certDir, "server.key")),
cert: fs.readFileSync(path.join(certDir, "server.crt")),
};
}
// The root-CA path needs an SNICallback fed by the per-host leaf issuer in
// `_internal/rootCaShim.cjs` (a CJS twin of `cert/rootCa.ts` +
// `tproxy/dynamicCert.ts` — see that file's header for why it's duplicated
// rather than imported). Resolved once during async bootstrap below.
async function loadRootCaSslOptions() {
const { loadOrCreateMitmCa, issueLeafCert, DynamicCertStore } = require("./_internal/rootCaShim.cjs");
const ca = await loadOrCreateMitmCa(certDir);
const certStore = new DynamicCertStore({ key: ca.key, cert: ca.cert });
const defaultHost = [...TARGET_HOSTS][0];
// Node's TLS server needs a default key/cert even with SNICallback (used
// only when a client sends no SNI at all) — warm the cache for the default
// host at the same time so the very first request for it is instant.
const defaultLeaf = await issueLeafCert(defaultHost, { key: ca.key, cert: ca.cert });
await certStore.getSecureContext(defaultHost);
return {
key: defaultLeaf.key,
cert: defaultLeaf.cert,
SNICallback: certStore.createSNICallback(),
};
}
// Log directory for request/response dumps
const LOG_DIR = path.join(__dirname, "../../logs/mitm");
@@ -552,235 +583,253 @@ async function intercept(req, res, bodyBuffer, override, sourceModel) {
}
}
const server = https.createServer(sslOptions, async (req, res) => {
stats.totalRequests++;
stats.lastRequestAt = new Date().toISOString();
writeStats();
// #6684: server creation (and everything below that closes over `server`) is
// wrapped in an async bootstrap because the root-CA path (`loadRootCaSslOptions`)
// needs to load/generate CA material before the TLS server can be created.
// server.cjs is spawned as a standalone child process (not `require()`d), so
// nothing needs its top-level exports to resolve synchronously — deferring
// server creation into this async function is safe. In "legacy" mode
// (default) `loadLegacySslOptions()` resolves synchronously, so startup
// timing for existing installs is unchanged.
async function startMitmServer() {
const sslOptions =
CERT_MODE === "root-ca" ? await loadRootCaSslOptions() : loadLegacySslOptions();
const bodyBuffer = await collectBodyRaw(req);
const host = String(req.headers.host || "")
.split(":")[0]
.toLowerCase();
const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null;
vlog(
1,
`[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}`
);
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
if (req.headers["x-omniroute-source"] === "omniroute") {
vlog(1, `[MITM] → PASSTHROUGH (OmniRoute source loop)`);
return passthrough(req, res, bodyBuffer);
}
if (!TARGET_HOSTS.has(host)) {
vlog(1, `[MITM] → PASSTHROUGH (host ${host} not in target list)`);
return passthrough(req, res, bodyBuffer);
}
const agentId = TARGET_HOST_AGENT.get(host) || "antigravity";
const routeConfig = standaloneRoutingShim.getAgentRouteConfig(agentId);
const isChatRequest = routeConfig.chatUrlPatterns.some((p) => req.url.includes(p));
if (!isChatRequest) {
vlog(1, `[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`);
return passthrough(req, res, bodyBuffer);
}
const mappedOverride = getMappedOverride(model, agentId);
if (!mappedOverride) {
vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`);
return passthrough(req, res, bodyBuffer);
}
stats.interceptedRequests++;
stats.lastInterceptAt = new Date().toISOString();
writeStats();
vlog(
1,
`[MITM] INTERCEPTED ${agentId} ${model}${mappedOverride.model || model}` +
(mappedOverride.reasoningEffort ? ` (reasoningEffort=${mappedOverride.reasoningEffort})` : "")
);
return intercept(req, res, bodyBuffer, mappedOverride, model);
});
// =========================================================================
// C1 — CONNECT handler: bypass + passthrough TCP support (plan 11 §4.6).
//
// Clients (browsers, IDE agents acting as HTTP proxy clients) send a
// CONNECT request before opening a TLS tunnel. The original `https.Server`
// has no built-in CONNECT handler because it expects connections to come
// pre-routed (typically via /etc/hosts DNS spoofing). For AgentBridge we
// also accept clients configured with HTTPS_PROXY/HTTP_PROXY, where every
// HTTPS request arrives as CONNECT. For those:
//
// - bypass hostname → raw TCP pipe upstream, NO TLS decrypt, NO log of
// body or headers. Privacy contract: bypass = "never see content".
// - passthrough (host not in TARGET_HOSTS, no bypass match) → raw TCP
// pipe upstream so the user's system never loses internet for hosts
// outside our scope. Acceptance criterion §12 #16.
// - target hostname → write 200 Connection Established and pipe the
// client socket into the local TLS-terminating port so the existing
// https.createServer can decrypt and route via the normal flow.
//
// Note: in the DNS-spoof mode (IDE points at 127.0.0.1 via /etc/hosts),
// IDEs reach the server directly without CONNECT; the existing
// `https.createServer` request handler still applies for those. The
// CONNECT handler only fires for clients that explicitly speak proxy.
// =========================================================================
function parseConnectAuthority(authority) {
// CONNECT host[:port]
const idx = authority.lastIndexOf(":");
if (idx === -1) return { host: authority.toLowerCase(), port: 443 };
const host = authority.slice(0, idx).toLowerCase();
const port = Number.parseInt(authority.slice(idx + 1), 10);
return {
host,
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443,
};
}
function rawTcpForward(clientSocket, head, host, port, label) {
const targetSocket = net.connect(port, host, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) targetSocket.write(head);
targetSocket.pipe(clientSocket);
clientSocket.pipe(targetSocket);
// Reap a half-open/hung tunnel after the idle timeout so neither side leaks
// an fd when the upstream never sends FIN/RST (Gap 10).
const destroyBoth = () => {
clientSocket.destroy();
targetSocket.destroy();
};
clientSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth);
targetSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth);
});
// Best-effort cleanup; never crash the proxy on tunnel errors.
const onErr = (label2) => (err) => {
console.error(`[MITM] ${label} TCP forward ${label2} error: ${err.message}`);
try {
clientSocket.destroy();
} catch {
// ignore
}
try {
targetSocket.destroy();
} catch {
// ignore
}
};
targetSocket.on("error", onErr("upstream"));
clientSocket.on("error", onErr("client"));
clientSocket.on("close", () => {
try {
targetSocket.destroy();
} catch {
// ignore
}
});
targetSocket.on("close", () => {
try {
clientSocket.destroy();
} catch {
// ignore
}
});
}
// CONNECT handler — scope note (plan 11 §4.6):
//
// This fires ONLY when a client uses this server as an explicit HTTPS proxy and
// sends a `CONNECT host:port` line *inside* an already-established TLS session
// (HTTPS-proxy-tunneled-in-TLS). The primary "no config required" AgentBridge
// flow does NOT use it: there the IDE is pointed at 127.0.0.1 via /etc/hosts DNS
// spoofing and opens TLS DIRECTLY, so requests are routed by the decrypted Host
// header in the request handler above (target → intercept, otherwise passthrough).
// Likewise, bypass/passthrough for *unmapped* hosts in the DNS-spoof model is
// handled by DNS scoping (only spoofed hosts ever resolve to 127.0.0.1), and the
// System-wide proxy mode (plan 12 §2.5.4) routes through httpProxyServer.ts (:8080),
// which has its own CONNECT handling. This handler is retained for the explicit-
// proxy edge case and to honor the routeBypass precedence (bypass > target >
// passthrough); true on-wire bypass-without-decrypt at :443 under direct TLS would
// require SNI sniffing on the raw 'connection' event, which is intentionally out
// of scope for this release.
server.on("connect", (req, clientSocket, head) => {
const authority = String(req.url || "");
const { host: connectHost, port: connectPort } = parseConnectAuthority(authority);
const decision = routeBypass(connectHost);
if (decision === "bypass") {
// Privacy: bypass hosts are never logged with body/headers and never
// TLS-decrypted. Only the hostname appears in console output.
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`);
rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass");
return;
}
if (decision === "target") {
// Hand the tunnel off to the local TLS-terminating server so the existing
// https.createServer request handler can decrypt and route. We write the
// 200 response ourselves and then `emit("connection")` so the TLS layer
// picks the socket up.
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)`);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) clientSocket.unshift(head);
server.emit("connection", clientSocket);
return;
}
// decision === "passthrough"
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)`);
rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough");
});
// Bound full-request / header / keep-alive lifetimes so a slow or hung client
// cannot pin a connection indefinitely (Gap 10).
server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; // hard cap on a full request
server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers
server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window
server.listen(LOCAL_PORT, () => {
stats.startedAt = new Date().toISOString();
writeStats();
console.log(`🚀 MITM ready on :${LOCAL_PORT}${ROUTER_URL}`);
});
server.on("connection", (socket) => {
// Guard against double-counting: a CONNECT "target" tunnel re-emits an
// already-counted socket into the TLS layer via emit("connection") above.
if (socket.__mitmCounted) return;
socket.__mitmCounted = true;
// Reap idle sockets so hung connections cannot exhaust fds (Gap 10).
socket.setTimeout(MITM_IDLE_TIMEOUT_MS, () => socket.destroy());
stats.activeConnections++;
writeStats();
socket.on("close", () => {
stats.activeConnections = Math.max(0, stats.activeConnections - 1);
const server = https.createServer(sslOptions, async (req, res) => {
stats.totalRequests++;
stats.lastRequestAt = new Date().toISOString();
writeStats();
});
});
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(`❌ Port ${LOCAL_PORT} already in use`);
} else if (error.code === "EACCES") {
console.error(`❌ Permission denied for port ${LOCAL_PORT}`);
} else {
console.error(`${error.message}`);
const bodyBuffer = await collectBodyRaw(req);
const host = String(req.headers.host || "")
.split(":")[0]
.toLowerCase();
const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null;
vlog(
1,
`[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}`
);
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
if (req.headers["x-omniroute-source"] === "omniroute") {
vlog(1, `[MITM] → PASSTHROUGH (OmniRoute source loop)`);
return passthrough(req, res, bodyBuffer);
}
if (!TARGET_HOSTS.has(host)) {
vlog(1, `[MITM] → PASSTHROUGH (host ${host} not in target list)`);
return passthrough(req, res, bodyBuffer);
}
const agentId = TARGET_HOST_AGENT.get(host) || "antigravity";
const routeConfig = standaloneRoutingShim.getAgentRouteConfig(agentId);
const isChatRequest = routeConfig.chatUrlPatterns.some((p) => req.url.includes(p));
if (!isChatRequest) {
vlog(1, `[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`);
return passthrough(req, res, bodyBuffer);
}
const mappedOverride = getMappedOverride(model, agentId);
if (!mappedOverride) {
vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`);
return passthrough(req, res, bodyBuffer);
}
stats.interceptedRequests++;
stats.lastInterceptAt = new Date().toISOString();
writeStats();
vlog(
1,
`[MITM] INTERCEPTED ${agentId} ${model}${mappedOverride.model || model}` +
(mappedOverride.reasoningEffort ? ` (reasoningEffort=${mappedOverride.reasoningEffort})` : "")
);
return intercept(req, res, bodyBuffer, mappedOverride, model);
});
// =========================================================================
// C1 — CONNECT handler: bypass + passthrough TCP support (plan 11 §4.6).
//
// Clients (browsers, IDE agents acting as HTTP proxy clients) send a
// CONNECT request before opening a TLS tunnel. The original `https.Server`
// has no built-in CONNECT handler because it expects connections to come
// pre-routed (typically via /etc/hosts DNS spoofing). For AgentBridge we
// also accept clients configured with HTTPS_PROXY/HTTP_PROXY, where every
// HTTPS request arrives as CONNECT. For those:
//
// - bypass hostname → raw TCP pipe upstream, NO TLS decrypt, NO log of
// body or headers. Privacy contract: bypass = "never see content".
// - passthrough (host not in TARGET_HOSTS, no bypass match) → raw TCP
// pipe upstream so the user's system never loses internet for hosts
// outside our scope. Acceptance criterion §12 #16.
// - target hostname → write 200 Connection Established and pipe the
// client socket into the local TLS-terminating port so the existing
// https.createServer can decrypt and route via the normal flow.
//
// Note: in the DNS-spoof mode (IDE points at 127.0.0.1 via /etc/hosts),
// IDEs reach the server directly without CONNECT; the existing
// `https.createServer` request handler still applies for those. The
// CONNECT handler only fires for clients that explicitly speak proxy.
// =========================================================================
function parseConnectAuthority(authority) {
// CONNECT host[:port]
const idx = authority.lastIndexOf(":");
if (idx === -1) return { host: authority.toLowerCase(), port: 443 };
const host = authority.slice(0, idx).toLowerCase();
const port = Number.parseInt(authority.slice(idx + 1), 10);
return {
host,
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443,
};
}
function rawTcpForward(clientSocket, head, host, port, label) {
const targetSocket = net.connect(port, host, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) targetSocket.write(head);
targetSocket.pipe(clientSocket);
clientSocket.pipe(targetSocket);
// Reap a half-open/hung tunnel after the idle timeout so neither side leaks
// an fd when the upstream never sends FIN/RST (Gap 10).
const destroyBoth = () => {
clientSocket.destroy();
targetSocket.destroy();
};
clientSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth);
targetSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth);
});
// Best-effort cleanup; never crash the proxy on tunnel errors.
const onErr = (label2) => (err) => {
console.error(`[MITM] ${label} TCP forward ${label2} error: ${err.message}`);
try {
clientSocket.destroy();
} catch {
// ignore
}
try {
targetSocket.destroy();
} catch {
// ignore
}
};
targetSocket.on("error", onErr("upstream"));
clientSocket.on("error", onErr("client"));
clientSocket.on("close", () => {
try {
targetSocket.destroy();
} catch {
// ignore
}
});
targetSocket.on("close", () => {
try {
clientSocket.destroy();
} catch {
// ignore
}
});
}
// CONNECT handler — scope note (plan 11 §4.6):
//
// This fires ONLY when a client uses this server as an explicit HTTPS proxy and
// sends a `CONNECT host:port` line *inside* an already-established TLS session
// (HTTPS-proxy-tunneled-in-TLS). The primary "no config required" AgentBridge
// flow does NOT use it: there the IDE is pointed at 127.0.0.1 via /etc/hosts DNS
// spoofing and opens TLS DIRECTLY, so requests are routed by the decrypted Host
// header in the request handler above (target → intercept, otherwise passthrough).
// Likewise, bypass/passthrough for *unmapped* hosts in the DNS-spoof model is
// handled by DNS scoping (only spoofed hosts ever resolve to 127.0.0.1), and the
// System-wide proxy mode (plan 12 §2.5.4) routes through httpProxyServer.ts (:8080),
// which has its own CONNECT handling. This handler is retained for the explicit-
// proxy edge case and to honor the routeBypass precedence (bypass > target >
// passthrough); true on-wire bypass-without-decrypt at :443 under direct TLS would
// require SNI sniffing on the raw 'connection' event, which is intentionally out
// of scope for this release.
server.on("connect", (req, clientSocket, head) => {
const authority = String(req.url || "");
const { host: connectHost, port: connectPort } = parseConnectAuthority(authority);
const decision = routeBypass(connectHost);
if (decision === "bypass") {
// Privacy: bypass hosts are never logged with body/headers and never
// TLS-decrypted. Only the hostname appears in console output.
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`);
rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass");
return;
}
if (decision === "target") {
// Hand the tunnel off to the local TLS-terminating server so the existing
// https.createServer request handler can decrypt and route. We write the
// 200 response ourselves and then `emit("connection")` so the TLS layer
// picks the socket up.
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)`);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) clientSocket.unshift(head);
server.emit("connection", clientSocket);
return;
}
// decision === "passthrough"
vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)`);
rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough");
});
// Bound full-request / header / keep-alive lifetimes so a slow or hung client
// cannot pin a connection indefinitely (Gap 10).
server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; // hard cap on a full request
server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers
server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window
server.listen(LOCAL_PORT, () => {
stats.startedAt = new Date().toISOString();
writeStats();
console.log(`🚀 MITM ready on :${LOCAL_PORT}${ROUTER_URL}`);
});
server.on("connection", (socket) => {
// Guard against double-counting: a CONNECT "target" tunnel re-emits an
// already-counted socket into the TLS layer via emit("connection") above.
if (socket.__mitmCounted) return;
socket.__mitmCounted = true;
// Reap idle sockets so hung connections cannot exhaust fds (Gap 10).
socket.setTimeout(MITM_IDLE_TIMEOUT_MS, () => socket.destroy());
stats.activeConnections++;
writeStats();
socket.on("close", () => {
stats.activeConnections = Math.max(0, stats.activeConnections - 1);
writeStats();
});
});
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(`❌ Port ${LOCAL_PORT} already in use`);
} else if (error.code === "EACCES") {
console.error(`❌ Permission denied for port ${LOCAL_PORT}`);
} else {
console.error(`${error.message}`);
}
process.exit(1);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});
}
startMitmServer().catch((err) => {
console.error(`${sanitizeErrorMessage(err && err.message)}`);
process.exit(1);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});

View File

@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { decideCertMigration } from "../../src/mitm/cert/migration.ts";
// #6684: migration gate between the legacy single self-signed leaf and the
// new persisted root-CA model. A trusted MITM CA that can sign a cert for
// ANY host is materially more powerful than the old fixed-SAN leaf, so an
// already-trusted install must never be silently upgraded.
function tmpCertDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mitm-migration-test-"));
}
function touch(filePath: string): void {
fs.writeFileSync(filePath, "test");
}
test("decideCertMigration: existing legacy leaf, no CA pair, flag off → stay on legacy leaf", () => {
const certDir = tmpCertDir();
try {
touch(path.join(certDir, "server.crt"));
touch(path.join(certDir, "server.key"));
assert.equal(decideCertMigration(certDir, false), "use-legacy-leaf");
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("decideCertMigration: no legacy leaf and no CA pair (fresh install) → use root CA", () => {
const certDir = tmpCertDir();
try {
assert.equal(decideCertMigration(certDir, false), "use-root-ca");
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("decideCertMigration: legacy leaf present but explicit opt-in flag on → use root CA", () => {
const certDir = tmpCertDir();
try {
touch(path.join(certDir, "server.crt"));
touch(path.join(certDir, "server.key"));
assert.equal(decideCertMigration(certDir, true), "use-root-ca");
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("decideCertMigration: CA pair already persisted → use root CA even without the flag", () => {
const certDir = tmpCertDir();
try {
touch(path.join(certDir, "server.crt"));
touch(path.join(certDir, "server.key"));
touch(path.join(certDir, "ca.crt"));
touch(path.join(certDir, "ca.key"));
assert.equal(decideCertMigration(certDir, false), "use-root-ca");
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("decideCertMigration: partial legacy pair (only server.crt) is treated as no legacy install", () => {
const certDir = tmpCertDir();
try {
touch(path.join(certDir, "server.crt"));
assert.equal(decideCertMigration(certDir, false), "use-root-ca");
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { X509Certificate } from "node:crypto";
import { generateMitmCa, issueLeafCert, DynamicCertStore } from "../../src/mitm/tproxy/dynamicCert.ts";
import { MITM_TOOL_HOSTS } from "../../src/shared/constants/mitmToolHosts.ts";
// #6684: leaf issuance for the full MITM_TOOL_HOSTS host set, reusing the CA
// module already proven for TPROXY (`tproxy/dynamicCert.ts`) — issuing per
// host is what lets the AgentBridge static server cover every tool host
// (previously only the 4 antigravity hosts had a matching SAN).
function allHosts(): string[] {
const set = new Set<string>();
for (const hosts of Object.values(MITM_TOOL_HOSTS)) {
for (const h of hosts) set.add(h);
}
return [...set];
}
test("issueLeafCert: every MITM_TOOL_HOSTS host resolves a SecureContext without throwing", async () => {
const ca = await generateMitmCa("Test MITM CA");
const store = new DynamicCertStore("Test MITM CA", ca);
for (const host of allHosts()) {
const ctx = await store.getSecureContext(host);
assert.ok(ctx, `expected a SecureContext for ${host}`);
}
});
test("issueLeafCert: issued leaf SAN matches the requested hostname", async () => {
const ca = await generateMitmCa("Test MITM CA");
for (const host of allHosts()) {
const leaf = await issueLeafCert(host, ca);
const leafPem = leaf.cert.split(/(?=-----BEGIN CERTIFICATE-----)/)[0];
const cert = new X509Certificate(leafPem);
assert.ok(
cert.subjectAltName && cert.subjectAltName.includes(host),
`expected SAN to include ${host}, got ${cert.subjectAltName}`
);
}
});
test("issueLeafCert: the leaf chain validates against the CA cert", async () => {
const ca = await generateMitmCa("Test MITM CA");
const host = "chain-check.example.com";
const leaf = await issueLeafCert(host, ca);
const leafPem = leaf.cert.split(/(?=-----BEGIN CERTIFICATE-----)/)[0];
const leafCert = new X509Certificate(leafPem);
const caCert = new X509Certificate(ca.cert);
assert.equal(leafCert.checkIssued(caCert), true);
assert.equal(leafCert.verify(caCert.publicKey), true);
});
test("DynamicCertStore: repeated getSecureContext for the same host is cached (no re-issuance)", async () => {
const ca = await generateMitmCa("Test MITM CA");
const store = new DynamicCertStore("Test MITM CA", ca);
const host = "cached-host.example.com";
const first = await store.getSecureContext(host);
const second = await store.getSecureContext(host);
assert.equal(first, second, "expected the identical cached SecureContext instance");
assert.equal(store.size, 1);
});
test("drift guard: a host added to mitmToolHosts.ts needs no matching entry in generate.ts/rootCa.ts", async () => {
// SNICallback issues a leaf on demand for any hostname — there is no
// per-host allowlist to keep in sync, unlike the legacy static leaf whose
// single SAN list was scoped to ANTIGRAVITY_TARGET.hosts (#6494).
const ca = await generateMitmCa("Test MITM CA");
const store = new DynamicCertStore("Test MITM CA", ca);
const neverRegisteredHost = "some-brand-new-tool-host.example.com";
assert.ok(!allHosts().includes(neverRegisteredHost));
const ctx = await store.getSecureContext(neverRegisteredHost);
assert.ok(ctx);
});

View File

@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadOrCreateMitmCa } from "../../src/mitm/cert/rootCa.ts";
// #6684: root-CA persistence — the OS trust prompt must only fire once per
// machine, so `loadOrCreateMitmCa()` must generate on first run and load
// (byte-identical) on every subsequent run, with a restrictively-permissioned
// private key file.
function tmpCertDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mitm-root-ca-test-"));
}
test("loadOrCreateMitmCa: first call with an empty dir generates and persists a CA", async () => {
const certDir = tmpCertDir();
try {
assert.equal(fs.existsSync(path.join(certDir, "ca.key")), false);
const ca = await loadOrCreateMitmCa(certDir);
assert.ok(ca.key.includes("PRIVATE KEY"));
assert.ok(ca.cert.includes("CERTIFICATE"));
assert.equal(fs.existsSync(path.join(certDir, "ca.key")), true);
assert.equal(fs.existsSync(path.join(certDir, "ca.crt")), true);
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("loadOrCreateMitmCa: a second call loads the same CA instead of regenerating", async () => {
const certDir = tmpCertDir();
try {
const first = await loadOrCreateMitmCa(certDir);
const second = await loadOrCreateMitmCa(certDir);
assert.equal(second.key, first.key);
assert.equal(second.cert, first.cert);
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("loadOrCreateMitmCa: the written CA private key file mode is 0o600", { skip: process.platform === "win32" }, async () => {
const certDir = tmpCertDir();
try {
const ca = await loadOrCreateMitmCa(certDir);
const mode = fs.statSync(ca.keyPath).mode & 0o777;
assert.equal(mode, 0o600);
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});
test("loadOrCreateMitmCa: the CA cert carries CA basicConstraints (matches generateMitmCa)", async () => {
const certDir = tmpCertDir();
try {
const ca = await loadOrCreateMitmCa(certDir);
const { X509Certificate } = await import("node:crypto");
const cert = new X509Certificate(ca.cert);
assert.equal(cert.ca, true);
} finally {
fs.rmSync(certDir, { recursive: true, force: true });
}
});