diff --git a/package-lock.json b/package-lock.json index d17a5f9d16f0..cde4ea308593 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2663,6 +2663,10 @@ "resolved": "ui-tui/packages/hermes-ink", "link": true }, + "node_modules/@hermes/root-tests": { + "resolved": "tests-js", + "link": true + }, "node_modules/@hermes/shared": { "resolved": "apps/shared", "link": true @@ -6401,6 +6405,17 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -19478,6 +19493,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "tests-js": { + "name": "@hermes/root-tests", + "devDependencies": { + "@types/plist": "^3.0.5", + "plist": "^3.1.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } + }, "ui-tui": { "name": "hermes-tui", "version": "0.0.1", @@ -19679,15 +19703,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" - }, - "tests-js": { - "name": "@hermes/root-tests", - "version": "0.0.0", - "dev": true - }, - "node_modules/@hermes/root-tests": { - "resolved": "tests-js", - "link": true } } } diff --git a/tests-js/desktop-mac-entitlements.test.ts b/tests-js/desktop-mac-entitlements.test.ts new file mode 100644 index 000000000000..9153e5416251 --- /dev/null +++ b/tests-js/desktop-mac-entitlements.test.ts @@ -0,0 +1,91 @@ +/** + * Regression for #37718: macOS microphone entitlement must be inherited. + * + * Hermes Desktop signs with ``hardenedRuntime: true`` and points + * electron-builder at two entitlement files (see ``apps/desktop/package.json``): + * + * - ``entitlements`` → ``electron/entitlements.mac.plist`` (the main app), and + * - ``entitlementsInherit`` → ``electron/entitlements.mac.inherit.plist`` + * (the Electron Helper / Setup processes). + * + * Under the hardened runtime, the process that actually opens the microphone + * is a Helper, which inherits the *inherit* plist. + * ``com.apple.security.device.audio-input`` lived only in the main plist, so + * macOS' TCC layer refused the microphone with: + * + * Prompting policy for hardened runtime; service: kTCCServiceMicrophone + * requires entitlement com.apple.security.device.audio-input but it is missing + * + * and never showed the permission prompt. These tests pin that every device + * entitlement granted to the main app is also granted to the inherited helpers. + * + * (Ported from tests/test_desktop_mac_entitlements.py — plist assertions about + * apps/desktop/electron/*.plist belong in the JS lane because the CI change + * classifier skips the Python suite on apps/-only PRs.) + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +import * as plist from 'plist' +import { test } from 'vitest' + +const REPO_ROOT = path.resolve(__dirname, '..') +const ELECTRON_DIR = path.join(REPO_ROOT, 'apps', 'desktop', 'electron') +const MAIN_PLIST = path.join(ELECTRON_DIR, 'entitlements.mac.plist') +const INHERIT_PLIST = path.join(ELECTRON_DIR, 'entitlements.mac.inherit.plist') + +const DEVICE_PREFIX = 'com.apple.security.device.' + +function loadEntitlements(plistPath: string): Record { + assert.ok(fs.existsSync(plistPath), `missing entitlements file: ${plistPath}`) + const data = plist.parse(fs.readFileSync(plistPath, 'utf-8')) + assert.ok( + typeof data === 'object' && data !== null && !Array.isArray(data), + `${path.basename(plistPath)} should parse to a dict` + ) + + return data as Record +} + +test('inherit plist grants microphone (regression #37718)', () => { + const inherit = loadEntitlements(INHERIT_PLIST) + assert.equal( + inherit['com.apple.security.device.audio-input'], + true, + 'entitlements.mac.inherit.plist must grant ' + + '`com.apple.security.device.audio-input`; without it the ' + + 'hardened-runtime Helper process is denied the microphone and no ' + + 'TCC prompt appears (#37718).' + ) +}) + +test('every device.* entitlement on the main app is also inherited', () => { + const main = loadEntitlements(MAIN_PLIST) + const inherit = loadEntitlements(INHERIT_PLIST) + + const missing = Object.entries(main) + .filter(([key, val]) => key.startsWith(DEVICE_PREFIX) && val === true) + .map(([key]) => key) + .filter((key) => inherit[key] !== true) + + assert.deepEqual( + missing, + [], + 'Device entitlements present in entitlements.mac.plist but missing from ' + + `entitlements.mac.inherit.plist: ${JSON.stringify(missing)}. ` + + 'Helper/Setup processes inherit the latter under hardenedRuntime, so ' + + 'any device access the app needs must be listed in both (#37718).' + ) +}) + +for (const plist of [MAIN_PLIST, INHERIT_PLIST]) { + test(`${path.basename(plist)} is a well-formed non-empty entitlement dict`, () => { + const data = loadEntitlements(plist) + assert.ok( + Object.keys(data).length > 0, + `${path.basename(plist)} should be a non-empty dict` + ) + }) +} diff --git a/tests-js/package.json b/tests-js/package.json index 692d02e13790..bc125f319808 100644 --- a/tests-js/package.json +++ b/tests-js/package.json @@ -12,6 +12,8 @@ }, "devDependencies": { "eslint": "^9.39.4", + "@types/plist": "^3.0.5", + "plist": "^3.1.0", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/tests/test_desktop_mac_entitlements.py b/tests/test_desktop_mac_entitlements.py deleted file mode 100644 index 5877d5b257c2..000000000000 --- a/tests/test_desktop_mac_entitlements.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Regression for #37718: macOS microphone entitlement must be inherited. - -Hermes Desktop signs with ``hardenedRuntime: true`` and points electron-builder -at two entitlement files (see ``apps/desktop/package.json``): - -* ``entitlements`` → ``electron/entitlements.mac.plist`` (the main app), and -* ``entitlementsInherit`` → ``electron/entitlements.mac.inherit.plist`` (the - Electron Helper / Setup processes). - -Under the hardened runtime, the process that actually opens the microphone is a -Helper, which inherits the *inherit* plist. ``com.apple.security.device.audio-input`` -lived only in the main plist, so macOS' TCC layer refused the microphone with:: - - Prompting policy for hardened runtime; service: kTCCServiceMicrophone - requires entitlement com.apple.security.device.audio-input but it is missing - -and never showed the permission prompt. These tests pin that every device -entitlement granted to the main app is also granted to the inherited helpers. -""" - -from __future__ import annotations - -import plistlib -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron" -MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist" -INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist" - -DEVICE_PREFIX = "com.apple.security.device." - - -def _load(plist: Path) -> dict: - assert plist.is_file(), f"missing entitlements file: {plist}" - with plist.open("rb") as fh: - return plistlib.load(fh) - - -def test_inherit_plist_grants_microphone() -> None: - """The helper-inherited plist must grant audio-input (regression #37718).""" - inherit = _load(INHERIT_PLIST) - assert inherit.get("com.apple.security.device.audio-input") is True, ( - "entitlements.mac.inherit.plist must grant " - "`com.apple.security.device.audio-input`; without it the hardened-runtime " - "Helper process is denied the microphone and no TCC prompt appears (#37718)." - ) - - -def test_device_entitlements_are_inherited() -> None: - """Every device.* entitlement on the main app must also be inherited.""" - main = _load(MAIN_PLIST) - inherit = _load(INHERIT_PLIST) - - main_device = { - key: val - for key, val in main.items() - if key.startswith(DEVICE_PREFIX) and val is True - } - missing = [key for key in main_device if inherit.get(key) is not True] - assert not missing, ( - "Device entitlements present in entitlements.mac.plist but missing from " - f"entitlements.mac.inherit.plist: {missing}. Helper/Setup processes inherit " - "the latter under hardenedRuntime, so any device access the app needs must " - "be listed in both (#37718)." - ) - - -@pytest.mark.parametrize("plist", [MAIN_PLIST, INHERIT_PLIST]) -def test_entitlement_files_are_valid_plists(plist: Path) -> None: - """Both entitlement files must remain well-formed plist dictionaries.""" - data = _load(plist) - assert isinstance(data, dict) and data, f"{plist.name} should be a non-empty dict"