mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
09f8a8268c
commit
dbf86b9234
4 changed files with 117 additions and 85 deletions
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
tests-js/desktop-mac-entitlements.test.ts
Normal file
91
tests-js/desktop-mac-entitlements.test.ts
Normal 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`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue