diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be1d3e4465..706b6c22f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,10 @@ permissions: contents: read env: + # CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow + # test installed a fake PEM on a persistent self-hosted runner and broke all + # system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts. + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" CI_NODE_VERSION: "24" CI_NODE_24_VERSION: "24" CI_NODE_26_VERSION: "26" diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index c1d5128a74..646c741811 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -31,6 +31,9 @@ concurrency: group: nightly-release-green cancel-in-progress: true +env: + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" + jobs: release-green: name: Validate active release branch diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fda787cfb9..f5c0aa9967 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,6 +14,10 @@ permissions: contents: read env: + # CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow + # test installed a fake PEM on a persistent self-hosted runner and broke all + # system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts. + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" CI_NODE_VERSION: "24" jobs: diff --git a/src/mitm/cert/install.ts b/src/mitm/cert/install.ts index 74dcf62d45..f6871b0bd2 100644 --- a/src/mitm/cert/install.ts +++ b/src/mitm/cert/install.ts @@ -184,6 +184,11 @@ export async function installCert(sudoPassword: string, certPath: string): Promi return; } + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1") { + console.log("[cert] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } + if (IS_WIN) { await installCertWindows(certPath); } else if (IS_MAC) { @@ -370,6 +375,11 @@ export async function uninstallCert(sudoPassword: string, certPath: string): Pro return; } + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1") { + console.log("[cert] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } + if (IS_WIN) { await uninstallCertWindows(); } else if (IS_MAC) { diff --git a/src/mitm/tproxy/caTrust.ts b/src/mitm/tproxy/caTrust.ts index 7d1cea8655..be7323ef2c 100644 --- a/src/mitm/tproxy/caTrust.ts +++ b/src/mitm/tproxy/caTrust.ts @@ -78,6 +78,10 @@ export async function installTproxyCa( sudoPassword = "", deps: Partial = {} ): Promise { + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1" && deps.run === undefined) { + console.log("[tproxy-ca] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } const d = { ...realDeps, ...deps }; if (d.platform() !== "linux") { throw new Error("TPROXY CA trust install is Linux-only."); @@ -103,6 +107,10 @@ export async function uninstallTproxyCa( sudoPassword = "", deps: Partial = {} ): Promise { + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1" && deps.run === undefined) { + console.log("[tproxy-ca] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } const d = { ...realDeps, ...deps }; if (d.platform() !== "linux") return; const cfg = d.certConfig(); diff --git a/tests/_setup/isolateDataDir.ts b/tests/_setup/isolateDataDir.ts index f13db04863..e3d943748b 100644 --- a/tests/_setup/isolateDataDir.ts +++ b/tests/_setup/isolateDataDir.ts @@ -34,3 +34,10 @@ if (!process.env.DATA_DIR) { } }); } + +// System-trust guard: the suite must NEVER mutate the OS trust store. On a +// persistent self-hosted runner the cert-flow integration test installed a fake +// 105-byte PEM into /usr/local/share/ca-certificates and update-ca-certificates +// baked it into the bundle, breaking ALL system TLS on the VM (2026-07-05). +// installCert/uninstallCert/installTproxyCa/uninstallTproxyCa no-op under this. +process.env.OMNIROUTE_SKIP_SYSTEM_TRUST = "1"; diff --git a/tests/unit/system-trust-test-guard.test.ts b/tests/unit/system-trust-test-guard.test.ts new file mode 100644 index 0000000000..969c3588f8 --- /dev/null +++ b/tests/unit/system-trust-test-guard.test.ts @@ -0,0 +1,42 @@ +// Guard: the test suite must NEVER touch the OS trust store. On 2026-07-05 the +// integration test "POST /cert: installs trust when cert exists" ran the REAL +// install path on a persistent self-hosted runner and wrote a 105-byte fake PEM +// into /usr/local/share/ca-certificates — update-ca-certificates then baked the +// invalid entry into ca-certificates.crt and broke ALL system TLS on the VM +// (curl error 77, apt cert failures, corrupted artifact downloads). Hosted +// runners are ephemeral, so the same write went unnoticed for months. +// +// OMNIROUTE_SKIP_SYSTEM_TRUST=1 (set globally in tests/_setup/isolateDataDir.ts) +// makes installCert/uninstallCert no-ops before any filesystem/spawn work. +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 { installCert } from "../../src/mitm/cert/install.ts"; + +test("isolateDataDir setup exports the system-trust guard for every test process", () => { + assert.equal(process.env.OMNIROUTE_SKIP_SYSTEM_TRUST, "1"); +}); + +test("installCert under the guard skips the OS mutation but keeps input contracts", async () => { + // Contract preserved: a missing cert file still throws (agent-bridge fallback + // #4546 depends on it to build the environment-skip result). + await assert.rejects(() => installCert("", "/nonexistent/omniroute-guard-test.pem")); + + // With a REAL (fake-content) cert file, the un-guarded path would go on to + // sudo/update-ca-certificates — under the guard it must resolve without + // mutating the OS trust store (this exact write bricked the VM's TLS). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-trust-guard-")); + const pem = path.join(dir, "omniroute-guard-test.pem"); + fs.writeFileSync( + pem, + "-----BEGIN CERTIFICATE-----\nMIIBpDCCAQ2gAwIBAgIUFakeGuardCertXX==\n-----END CERTIFICATE-----\n" + ); + try { + await installCert("", pem); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});