Files
hermes-agent/tests-js/desktop-mac-usage-descriptions.test.ts
ethernet 47f4ab3a17 feat(desktop): bundle, publish, and update the desktop app as MSIX
Wire the desktop app onto the pm store for real distribution:
- MSIX bundle: electron-builder config, appx assets, manifest, copilot
  key + deep-link routing, App Installer + Windows Store variant
  (sign only the msix; inner binaries covered by the package block map)
- Rust CLI shim (apps/desktop/shim) — bundled builds run from the store
  python + shim, never the venv; payload symlinks relativized so the
  relocatable venv survives relocation
- Cloudflare R2 release pipeline: publish binaries + update feeds,
  nightly channels/tags, stamp-first version resolution
- Update system: gate, uninstall steward, boot bootstrap, release
  channels, update receipts
- install.ps1 reduced to a 361-line stage-protocol bootstrapper (heavy
  deps are pm's job); darwin updater + update-channel mirror ripped
- doctor: main's re-landed TCC anchor kept, termux branches removed

Rebuilt from ethie/pm onto the pm-store stack. 22 hot files hand-merged;
uv.lock + package-lock.json keep main's newer dep tree; test_engines
reads the pm/lock.json pin; lazy_deps.py deleted (all 222 importers
migrated to pm in the foundation commit).
2026-08-31 18:00:48 -04:00

240 lines
9.4 KiB
TypeScript

/**
* Regression for #54551: macOS Info.plist privacy usage descriptions
* declared by the Desktop electron-builder config
* (`apps/desktop/package.json -> build.mac.extendInfo`) must pin every
* `NS*UsageDescription` key the renderer relies on.
*
* Each entry is a key/value pair that lands in the packaged Hermes.app's
* Info.plist via electron-builder's `extendInfo` merge. Missing or mis-stated
* keys cause macOS to either silently deny the related API or surface a
* mysteriously-worded system permission prompt at runtime (TCC's
* `kTCCServiceMediaLibrary`, `kTCCServiceAppleEvents`, etc.).
*
* The Desktop renderer initializes Chromium's audio stack on user gesture
* (completion chimes, TTS playback, voice mode). On macOS 26+, that init can
* register the helper with the media subsystem and surface as a
* "Hermes wants to access Music" prompt unless the Info.plist disclaims it
* explicitly. This test pins every usage-description string the desktop
* currently relies on so accidental drops break CI instead of breaking users.
*
* Why this test lives in tests-js/, not tests/*.py
* -------------------------------------------------
*
* `AGENTS.md:1319-1329` requires assertions about `package.json` and JS-side
* artifacts to live in the JS/Vitest suite: the CI change classifier can
* skip Python coverage on a JS-only PR (the classifier's `python` lane is
* skipped when all paths match `_FRONTEND` or `_PY_SKIP`, both of which
* cover `apps/desktop/package.json`). A regression would then go green on
* the PR and red on `main` where the classifier fails open. See also
* `tests-js/desktop-mac-entitlements.test.ts` which ports an earlier Python
* entitlements regression for the same reason.
*
* Why this test exists
* --------------------
*
* The project has a recurring class of bug: a macOS privacy-sensitive API is
* called at runtime, but the Info.plist doesn't declare the corresponding
* `NS*UsageDescription` key, so the system prompt is either silent (with a
* generic "denied" error to the agent) or worded in a way that confuses the
* user ("Hermes wants to access Music" when Hermes never touches the Music
* library). The closed-PR family (#59486 / its duplicates #59833, #59915,
* #59950, #60013 for Contacts; #39854 for Calendar; #64582 for Reminders)
* established that the right fix shape is: add the key + pin it in a test.
* This file is the canonical test for that pattern at the Desktop layer.
*
* When adding a new NS*UsageDescription key to `build.mac.extendInfo`, add a
* matching row to EXPECTED_USAGE_DESCRIPTIONS below. The drift-protection
* assertion at the bottom of this file will fail otherwise.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { test } from 'vitest'
const REPO_ROOT = path.resolve(__dirname, '..')
const DESKTOP_DIR = path.join(REPO_ROOT, 'apps', 'desktop')
// The desktop build config lives in electron-builder.config.cjs — the repo
// deliberately keeps package.json free of a `build` field (see the config
// header: "run-electron-builder.mjs always passes --config, so a stray
// package.json field would be silently ignored"). extendInfo is read from
// that file, exactly what electron-builder consumes.
const DESKTOP_CONFIG = path.join(DESKTOP_DIR, 'electron-builder.config.cjs')
interface UsageDescriptionRow {
key: string
requiredSubstring: string
reason: string
}
function desktopConfig(): Record<string, unknown> {
assert.ok(fs.existsSync(DESKTOP_CONFIG), `missing ${DESKTOP_CONFIG}`)
// The config is a .cjs module (CommonJS); require it like the builder does.
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require(DESKTOP_CONFIG) as Record<string, unknown>
}
function extendInfo(): Record<string, string> {
const config = desktopConfig()
const mac = (config.mac ?? {}) as Record<string, unknown>
assert.ok(
typeof mac.extendInfo === 'object' &&
mac.extendInfo !== null &&
!Array.isArray(mac.extendInfo),
'build.mac.extendInfo is missing or invalid in electron-builder.config.cjs'
)
const extend = mac.extendInfo as Record<string, unknown>
// Narrow to Record<string, string> with a runtime guard — the value type
// for NS*UsageDescription is string, but electron-builder's `extendInfo`
// accepts arbitrary plist scalars (bool, number, array, object) and we want
// a clean assertion error here, not a downstream `value.trim is not a
// function` crash in the whitespace test. Only the privacy usage keys must
// be strings; other plist scalars (LSRequiresNativeExecution, etc.) are
// legitimate booleans/numbers.
for (const [key, value] of Object.entries(extend)) {
if (!key.startsWith('NS') || !key.endsWith('UsageDescription')) {
continue
}
assert.equal(
typeof value,
'string',
`\`${key}\` in build.mac.extendInfo must be a string (got ${typeof value})`
)
}
return extend as Record<string, string>
}
// Each entry: Info.plist key, required substring (case-insensitive), and a
// plain-language reason. The substring check lets future copy edits pass
// while still catching silent drops of the key itself.
const EXPECTED_USAGE_DESCRIPTIONS: UsageDescriptionRow[] = [
{
key: 'NSMicrophoneUsageDescription',
requiredSubstring: 'microphone',
reason: 'Microphone capture is required for voice input mode.'
},
{
key: 'NSAudioCaptureUsageDescription',
requiredSubstring: 'audio',
reason: 'Audio capture backs the voice conversation pipeline.'
},
{
key: 'NSCameraUsageDescription',
requiredSubstring: 'camera',
reason: 'Camera access is requested by plugins/features the user enables.'
},
{
key: 'NSAppleMusicUsageDescription',
requiredSubstring: 'Music',
reason:
"Disclaim MediaLibrary access so the system audio stack does not " +
'surface a misleading Apple Music permission prompt ' +
'(kTCCServiceMediaLibrary) when the renderer initializes audio for ' +
'completion chimes, TTS, or voice.'
},
{
key: 'NSCalendarsUsageDescription',
requiredSubstring: 'Calendar',
reason: 'Calendar access backs meeting and scheduling support (#64571).'
},
{
key: 'NSCalendarsFullAccessUsageDescription',
requiredSubstring: 'Calendar',
reason: 'macOS 14+ full-access variant of the calendar declaration.'
},
{
key: 'NSRemindersUsageDescription',
requiredSubstring: 'Reminders',
reason: 'Reminders access backs personal-assistant scheduling (#64571).'
},
{
key: 'NSRemindersFullAccessUsageDescription',
requiredSubstring: 'Reminders',
reason: 'macOS 14+ full-access variant of the reminders declaration.'
},
{
key: 'NSScreenCaptureUsageDescription',
requiredSubstring: 'screen',
reason: 'macOS 15+ periodic screen-recording re-prompts show this copy.'
},
{
key: 'NSLocalNetworkUsageDescription',
requiredSubstring: 'local network',
reason:
'macOS 15+ Local Network Privacy silently denies undeclared apps ' +
'(#81563); declaration is required for the prompt to appear at all.'
}
]
test.each(EXPECTED_USAGE_DESCRIPTIONS)(
'`$key` is declared in build.mac.extendInfo',
({ key, requiredSubstring, reason }) => {
const info = extendInfo()
const value = info[key]
assert.ok(
value !== undefined,
`Info.plist privacy usage description \`${key}\` is missing from ` +
'electron-builder.config.cjs mac.extendInfo. macOS will surface ' +
'a misleading system prompt or silently deny the related API.\n' +
`Reason: ${reason}`
)
assert.ok(
value.toLowerCase().includes(requiredSubstring.toLowerCase()),
`\`${key}\` exists but does not mention '${requiredSubstring}'. ` +
`Current value: ${JSON.stringify(value)}. Reason: ${reason}`
)
}
)
test('every extendInfo value is free of leading/trailing whitespace and newlines', () => {
const info = extendInfo()
for (const [key, value] of Object.entries(info)) {
// Only string values carry whitespace concerns; plist scalars
// (booleans/numbers like LSRequiresNativeExecution) are exempt.
if (typeof value !== 'string') {
continue
}
assert.equal(
value,
value.trim(),
`\`${key}\` in build.mac.extendInfo has leading/trailing whitespace: ` +
JSON.stringify(value)
)
// electron-builder writes strings as-is; newlines would render as
// literal control chars in the macOS prompt.
assert.ok(
!value.includes('\n') && !value.includes('\r'),
`\`${key}\` contains a newline; macOS will render it as a control ` +
'character in the system permission prompt.'
)
}
})
test('every NS*UsageDescription in extendInfo is pinned in this test', () => {
const info = extendInfo()
const declaredKeys = new Set(EXPECTED_USAGE_DESCRIPTIONS.map((row) => row.key))
// Non-privacy keys (CFBundleDisplayName etc.) are exempt — this test
// only governs NS*UsageDescription entries.
const privacyKeysInPlist = new Set(
Object.keys(info).filter(
(k) => k.startsWith('NS') && k.endsWith('UsageDescription')
)
)
const missing = [...privacyKeysInPlist].filter((k) => !declaredKeys.has(k))
assert.deepEqual(
missing,
[],
`extendInfo declares privacy usage keys ${JSON.stringify(missing.sort())} ` +
'that this test does not pin. Add them to EXPECTED_USAGE_DESCRIPTIONS ' +
'with a reason, or remove them from the build config.'
)
})