From 2bc6cc421a6a4d2af9b4d3a9b456362aa2d7e0fe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 12:05:56 -0500 Subject: [PATCH] fix(desktop): trust macOS keychain CAs for remote gateways like Windows installWindowsSystemCaTrust only handled win32, so on macOS a remote gateway fronted by a keychain-trusted private CA rendered fine but every main-process Node https call failed with 'unable to get local issuer certificate'. Generalize it to installSystemCaTrust covering both stores tls.getCACertificates('system') can enumerate: the Windows cert store and the macOS keychain (user + System roots, honoring the SSL 'Always Trust' policy; Node >= 22.15). Linux stays excluded since its 'system' store is the OpenSSL scan the default trust already covers. Fixes https://github.com/NousResearch/hermes-agent/issues/57241 --- .../{windows-system-ca.ts => system-ca.ts} | 0 apps/desktop/electron/main.ts | 8 +-- ...ws-system-ca.test.ts => system-ca.test.ts} | 54 +++++++++++++++---- .../{windows-system-ca.ts => system-ca.ts} | 16 ++++-- 4 files changed, 58 insertions(+), 20 deletions(-) rename apps/desktop/electron/fixtures/{windows-system-ca.ts => system-ca.ts} (100%) rename apps/desktop/electron/{windows-system-ca.test.ts => system-ca.test.ts} (61%) rename apps/desktop/electron/{windows-system-ca.ts => system-ca.ts} (74%) diff --git a/apps/desktop/electron/fixtures/windows-system-ca.ts b/apps/desktop/electron/fixtures/system-ca.ts similarity index 100% rename from apps/desktop/electron/fixtures/windows-system-ca.ts rename to apps/desktop/electron/fixtures/system-ca.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 2c2bb542b0..8ca3740c3d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -598,7 +598,7 @@ import { shouldSurfaceErrorForRendererStackCookieCrashLoop, writeGpuStackCookieMarker } from './windows-stack-cookie-fallback' -import { installWindowsSystemCaTrust } from './windows-system-ca' +import { installSystemCaTrust } from './system-ca' import { readWindowsUserEnvVar } from './windows-user-env' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' import { readWslWindowsClipboardImage } from './wsl-clipboard-image' @@ -18495,14 +18495,14 @@ app.whenReady().then(() => { startChromiumLogWatcher(CHROMIUM_LOG_PATH) } - const systemCa = installWindowsSystemCaTrust(tls) + const systemCa = installSystemCaTrust(tls) if (systemCa.applied) { rememberLog( - `[tls] trusting ${systemCa.systemCertificateCount} Windows system CA certificate(s) for backend connections` + `[tls] trusting ${systemCa.systemCertificateCount} OS CA certificate(s) for backend connections` ) } else if (systemCa.error) { - rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`) + rememberLog(`[tls] could not load OS system CA certificates: ${systemCa.error}`) } // Keyring-less Linux `--password-store=basic` support. This must run before diff --git a/apps/desktop/electron/windows-system-ca.test.ts b/apps/desktop/electron/system-ca.test.ts similarity index 61% rename from apps/desktop/electron/windows-system-ca.test.ts rename to apps/desktop/electron/system-ca.test.ts index fdf6af80a7..ab7c6a3ccb 100644 --- a/apps/desktop/electron/windows-system-ca.test.ts +++ b/apps/desktop/electron/system-ca.test.ts @@ -3,8 +3,8 @@ import { X509Certificate } from 'node:crypto' import { afterEach, test, vi } from 'vitest' -import { bundledRoot, expiredRoot, privateRoot } from './fixtures/windows-system-ca' -import { installWindowsSystemCaTrust, type NodeTlsCaApi } from './windows-system-ca' +import { bundledRoot, expiredRoot, privateRoot } from './fixtures/system-ca' +import { installSystemCaTrust, type NodeTlsCaApi } from './system-ca' afterEach(() => vi.restoreAllMocks()) @@ -16,7 +16,7 @@ test('excludes expired roots and deduplicates real certificates with defaults fi [expiredRoot, bundledRoot.replaceAll('\n', '\r\n'), privateRoot, privateRoot, 'unparseable-system'] ) - const result = installWindowsSystemCaTrust(tlsApi, 'win32') + const result = installSystemCaTrust(tlsApi, 'win32') assert.deepEqual(tlsApi.installed, [[bundledRoot, 'unparseable-default', privateRoot, 'unparseable-system']]) assert.deepEqual(result, { applied: true, systemCertificateCount: 2, totalCertificateCount: 4 }) @@ -26,7 +26,7 @@ test('excludes a root at its exact expiry while retaining valid defaults', () => vi.spyOn(Date, 'now').mockReturnValue(new X509Certificate(expiredRoot).validToDate.getTime()) const tlsApi = fakeTlsApi([bundledRoot], [expiredRoot]) - const result = installWindowsSystemCaTrust(tlsApi, 'win32') + const result = installSystemCaTrust(tlsApi, 'win32') assert.deepEqual(tlsApi.installed, [[bundledRoot]]) assert.deepEqual(result, { applied: true, systemCertificateCount: 0, totalCertificateCount: 1 }) @@ -52,7 +52,7 @@ function fakeTlsApi( test('installs Windows system CAs without dropping existing defaults', () => { const tlsApi = fakeTlsApi(['mozilla-root', 'extra-ca'], ['machine-root', 'user-root']) - const result = installWindowsSystemCaTrust(tlsApi, 'win32') + const result = installSystemCaTrust(tlsApi, 'win32') assert.deepEqual(tlsApi.installed, [['mozilla-root', 'extra-ca', 'machine-root', 'user-root']]) assert.deepEqual(result, { @@ -62,7 +62,39 @@ test('installs Windows system CAs without dropping existing defaults', () => { }) }) -test.each(['darwin', 'linux'] as const)('does not inspect or replace CAs on %s', platform => { +test('installs macOS keychain CAs (the renderer already trusts them; Node https did not)', () => { + // #57241: a remote gateway fronted by a private/homelab CA trusted in Keychain Access + // loads in the Chromium renderer but fails every main-process Node https call with + // `unable to get local issuer certificate`. On darwin the same installer must fold the + // keychain's user + System roots into the default trust store. + const tlsApi = fakeTlsApi(['mozilla-root'], ['homelab-root', 'corp-root']) + + const result = installSystemCaTrust(tlsApi, 'darwin') + + assert.deepEqual(tlsApi.installed, [['mozilla-root', 'homelab-root', 'corp-root']]) + assert.deepEqual(result, { + applied: true, + systemCertificateCount: 2, + totalCertificateCount: 3 + }) +}) + +test('darwin without keychain trust additions leaves the defaults untouched', () => { + // A machine with no user-installed anchors enumerates zero 'system' certs; the default + // trust store must stay untouched (also the pre-keychain-reader Node behavior). + const tlsApi = fakeTlsApi(['mozilla-root'], []) + + const result = installSystemCaTrust(tlsApi, 'darwin') + + assert.deepEqual(tlsApi.installed, []) + assert.deepEqual(result, { + applied: false, + systemCertificateCount: 0, + totalCertificateCount: 1 + }) +}) + +test('does not inspect or replace CAs on linux', () => { let reads = 0 const tlsApi: NodeTlsCaApi = { @@ -76,7 +108,7 @@ test.each(['darwin', 'linux'] as const)('does not inspect or replace CAs on %s', } } - const result = installWindowsSystemCaTrust(tlsApi, platform) + const result = installSystemCaTrust(tlsApi, 'linux') assert.equal(reads, 0) assert.deepEqual(result, { @@ -86,10 +118,10 @@ test.each(['darwin', 'linux'] as const)('does not inspect or replace CAs on %s', }) }) -test('leaves the existing defaults untouched when Windows has no system CAs', () => { +test('leaves the existing defaults untouched when the OS store has no system CAs', () => { const tlsApi = fakeTlsApi(['mozilla-root'], []) - const result = installWindowsSystemCaTrust(tlsApi, 'win32') + const result = installSystemCaTrust(tlsApi, 'win32') assert.deepEqual(tlsApi.installed, []) assert.deepEqual(result, { @@ -99,7 +131,7 @@ test('leaves the existing defaults untouched when Windows has no system CAs', () }) }) -test('fails open when the runtime cannot load the Windows certificate store', () => { +test('fails open when the runtime cannot load the OS certificate store', () => { const tlsApi: NodeTlsCaApi = { getCACertificates(type = 'default') { if (type === 'system') { @@ -113,7 +145,7 @@ test('fails open when the runtime cannot load the Windows certificate store', () } } - const result = installWindowsSystemCaTrust(tlsApi, 'win32') + const result = installSystemCaTrust(tlsApi, 'win32') assert.deepEqual(result, { applied: false, diff --git a/apps/desktop/electron/windows-system-ca.ts b/apps/desktop/electron/system-ca.ts similarity index 74% rename from apps/desktop/electron/windows-system-ca.ts rename to apps/desktop/electron/system-ca.ts index 479c249534..f1f3576e62 100644 --- a/apps/desktop/electron/windows-system-ca.ts +++ b/apps/desktop/electron/system-ca.ts @@ -5,15 +5,21 @@ interface NodeTlsCaApi { setDefaultCACertificates(certificates: string[]): void } -interface WindowsSystemCaResult { +interface SystemCaResult { applied: boolean systemCertificateCount: number totalCertificateCount: number error?: string } -function installWindowsSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.platform): WindowsSystemCaResult { - if (platform !== 'win32') { +// Platforms whose OS trust store tls.getCACertificates('system') can enumerate: the Windows +// cert store and the macOS keychain (Node reads user + System keychains there, honoring the +// "Always Trust" SSL policy — Node ≥ 22.15). Linux is deliberately absent: its 'system' store +// is the OpenSSL directory scan, which the default trust already covers. +const SYSTEM_CA_PLATFORMS = new Set(['win32', 'darwin']) + +function installSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.platform): SystemCaResult { + if (!SYSTEM_CA_PLATFORMS.has(platform)) { return { applied: false, systemCertificateCount: 0, @@ -75,5 +81,5 @@ function installWindowsSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.pl } } -export { installWindowsSystemCaTrust } -export type { NodeTlsCaApi, WindowsSystemCaResult } +export { installSystemCaTrust } +export type { NodeTlsCaApi, SystemCaResult }