test: port macOS entitlements test from Python to vitest

tests/test_desktop_mac_entitlements.py asserts about
apps/desktop/electron/*.plist — the same CI blind spot as the other
ported tests: the change classifier routes apps/ changes to the
frontend lane, so a PR touching only the plists would skip the Python
suite and the regression would go green on the PR and red on main.

Ported to tests-js/desktop-mac-entitlements.test.ts so it runs in the
correct lane. All three tests carry over: the inherit plist grants
audio-input (regression #37718), every device.* entitlement on the
main app is also inherited, and both files remain well-formed.

Parsing uses the `plist` package (pinned to ^3.1.0, the version
already present in the workspace lockfile, so no new transitive
packages) plus `@types/plist` — Node's stand-in for plistlib.

Verified: tests-js `npm run check` passes (typecheck + 9/9 tests), and
a mutation run (removing audio-input from the inherit plist) turns
both regression tests red.
This commit is contained in:
ethernet 2026-07-15 16:42:53 -04:00
parent 09f8a8268c
commit dbf86b9234
4 changed files with 117 additions and 85 deletions

33
package-lock.json generated
View file

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

View file

@ -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<string, unknown> {
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<string, unknown>
}
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`
)
})
}

View file

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

View file

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