From 5d171ffbbe6264261bbca938981418e8f553d58f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 17:28:32 -0500 Subject: [PATCH] fix(desktop): stable macOS signing identity so TCC grants survive rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local/self-updated macOS builds were finished with a plain 'codesign --force --deep --sign -', leaving a cdhash-only Designated Requirement and stripping electron-builder's entitlements. Every rebuild changes the cdhash, so TCC treats the new bundle as different code and forgets Full Disk Access, Desktop/Downloads/Documents, Accessibility, Automation, and microphone grants — users re-approve everything after every update. Rework the relaunch fixup to sign inside-out (standalone Mach-O binaries, nested frameworks/helpers, then the main bundle), preserving the repo's entitlement plists, and pin an identifier-based Designated Requirement when signing ad-hoc so TCC has a stable identity to persist. Opt-in desktop.macos_signing_identity names a persistent keychain cert (self-signed Code Signing cert works — no Apple Developer account) for a certificate-anchored DR, the strongest form. An intact Developer ID signature is detected and never clobbered, callers can pass the publisher-signing decision explicitly so a later dotenv load can't flip it, and the legacy deep ad-hoc sign remains the last-resort fallback. Co-authored-by: lewis4x4 Co-authored-by: natebransc Co-authored-by: caseyanthony Co-authored-by: gvago Co-authored-by: twe-cloud --- hermes_cli/config.py | 9 + hermes_cli/main.py | 243 ++++++++++++++++++++-- tests/hermes_cli/test_gui_command.py | 213 +++++++++++++++++++ tests/hermes_cli/test_set_config_value.py | 8 + 4 files changed, 453 insertions(+), 20 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e80eb6b77b42..5e147004fb64 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3787,6 +3787,15 @@ DEFAULT_CONFIG = { # false - always keep GPU acceleration on, even over a remote display. # Bridged to the HERMES_DESKTOP_DISABLE_GPU env var the Electron app reads. "disable_gpu": "auto", + # macOS only: optional persistent code-signing identity (a cert in the + # login keychain — a self-signed "Code Signing" cert from Keychain + # Access works; no Apple Developer account needed) used to re-sign + # locally rebuilt desktop apps. A certificate-anchored Designated + # Requirement stays stable across rebuilds, so TCC grants (Full Disk + # Access, Desktop/Downloads/Documents, Accessibility, Automation, + # microphone) survive every update. Empty keeps the default stable + # ad-hoc signing (identifier-pinned requirement). + "macos_signing_identity": "", # Auto-continue a turn that was killed mid-run by an app/backend/machine # crash: resuming that session re-submits the interrupted prompt (shown # as a "resumed interrupted turn" event) if the interruption is fresh. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 35880ace5035..73c119fd7f3c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6365,41 +6365,244 @@ def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]: return stopped -def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None: - """Make a locally-built (unsigned) macOS desktop app survive in-place self-update. +def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]: + """Return a bundle/framework CFBundleIdentifier for local macOS signing.""" + import plistlib - An ad-hoc-signed .app has no stable Designated Requirement (no Team ID), so - when the self-updater rebuilds the bundle in place with a fresh build (a new, - different cdhash) Gatekeeper/LaunchServices treats the changed code as - tampering and macOS reports "Hermes is damaged and can't be opened." The - bundle also inherits the com.apple.quarantine flag from the downloaded - installer process chain. Both make the relaunch fail. + info = bundle / "Contents" / "Info.plist" + if not info.exists() and bundle.suffix == ".framework": + candidates = list(bundle.glob("Versions/*/Resources/Info.plist")) + list( + bundle.glob("Resources/Info.plist") + ) + if candidates: + info = candidates[0] + if not info.exists(): + return None + try: + data = plistlib.loads(info.read_bytes()) + except Exception: + return None + ident = data.get("CFBundleIdentifier") + return str(ident) if ident else None - Clearing the quarantine xattrs and re-applying a clean deep ad-hoc signature - (omitting the hardened-runtime flag, which is meaningless without a real - Developer ID) lets the rebuilt app relaunch. No-op when a real signing - identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY) so a properly - signed/notarized build is never clobbered. Best-effort: never raises. + +def _desktop_macos_local_signing_identity() -> Optional[str]: + """Return the opt-in keychain identity for local macOS desktop signing. + + ``desktop.macos_signing_identity`` in config.yaml names a persistent + code-signing certificate in the user's login keychain (a self-signed + "Code Signing" cert made in Keychain Access is enough — no Apple Developer + account needed). Signing with any identity gives the app a + certificate-anchored Designated Requirement, which is the strongest way to + keep macOS TCC grants (Full Disk Access, Accessibility, Automation, Files + and Folders) stable across local rebuilds. Empty/unset keeps the default + identifier-pinned ad-hoc signing. """ if sys.platform != "darwin": - return - if os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY"): - return + return None + try: + from hermes_cli.config import load_config + + desktop = load_config().get("desktop", {}) + if not isinstance(desktop, dict): + return None + identity = desktop.get("macos_signing_identity") + if not isinstance(identity, str): + return None + return identity.strip() or None + except Exception as exc: + print( + " (warning: could not load desktop.macos_signing_identity: " + f"{exc}; falling back to ad-hoc signing)" + ) + return None + + +def _desktop_macos_has_valid_real_signature(app: Path) -> bool: + """True when the bundle carries an intact non-ad-hoc (Team ID) signature. + + Used to make the relaunch fixup a no-op on properly signed/notarized + builds even when CSC_LINK / APPLE_SIGNING_IDENTITY aren't in the + environment (e.g. a release DMG install being repaired) — clobbering a + Developer ID signature with an ad-hoc one would reset TCC grants and can + break the hardened runtime. A *stale* real signature (in-place rebuild + tampered with the bundle) fails --verify and returns False so the fixup + can repair it. + """ + codesign = shutil.which("codesign") + if not codesign: + return False + try: + info = subprocess.run( + [codesign, "-dv", str(app)], check=False, capture_output=True, text=True + ) + output = f"{info.stdout}\n{info.stderr}" + if info.returncode != 0 or "TeamIdentifier=" not in output \ + or "TeamIdentifier=not set" in output: + return False + verify = subprocess.run( + [codesign, "--verify", "--deep", "--strict", str(app)], + check=False, capture_output=True, + ) + return verify.returncode == 0 + except Exception: + return False + + +def _desktop_macos_local_codesign( + app: Path, *, desktop_dir: Path, identity: str = "-" +) -> bool: + """Re-sign a local Desktop build so macOS TCC grants survive rebuilds. + + A plain ``codesign --deep --sign -`` leaves the bundle with a cdhash-only + Designated Requirement and strips electron-builder's entitlements. Every + rebuild changes the cdhash, so TCC (Full Disk Access, Accessibility, + Automation, Files and Folders: Desktop/Downloads/Documents, microphone) + treats the rebuilt app as different code and the user must re-grant + everything — and the lost entitlements break microphone/JIT under the + hardened runtime. + + Instead, sign inside-out (standalone Mach-O binaries, then nested + frameworks/helper apps, then the main bundle), preserving the repo's + entitlement plists, and pin an explicit identifier-based Designated + Requirement when signing ad-hoc. With a real ``identity`` the certificate + anchors the DR, so no explicit requirement is needed. Raises on signing + failure; returns True after strict verification passes. + """ + codesign = shutil.which("codesign") + if not codesign: + return False + + ent_main = desktop_dir / "electron" / "entitlements.mac.plist" + ent_inherit = desktop_dir / "electron" / "entitlements.mac.inherit.plist" + + def sign_path( + path: Path, + *, + entitlements: Optional[Path] = None, + identifier: Optional[str] = None, + runtime: bool = True, + ) -> None: + args = [codesign, "--force", "--sign", identity, "--timestamp=none"] + if runtime: + args += ["--options", "runtime"] + if entitlements is not None and entitlements.exists(): + args += ["--entitlements", str(entitlements)] + if identifier and identity == "-": + # Ad-hoc signatures get a cdhash-only DR by default; pin an + # identifier-based DR so TCC has something stable to persist. + args += ["--requirements", f'=designated => identifier "{identifier}"'] + args.append(str(path)) + subprocess.run(args, check=True, capture_output=True) + + # 1) Standalone Mach-O files (native modules, dylibs, crashpad handler). + # Compare paths relative to the app root — the absolute path always + # contains the outer Hermes.app component, so an absolute-parts check + # would skip every file. + contents = app / "Contents" + standalone: list[Path] = [] + for root, _dirs, files in os.walk(contents): + root_path = Path(root) + rel_parts = root_path.relative_to(app).parts + if any(part.endswith(".app") for part in rel_parts): + continue # nested helper apps are signed as bundles below + for name in files: + fp = root_path / name + if name in {"chrome_crashpad_handler", "spawn-helper"} or fp.suffix in { + ".node", + ".dylib", + }: + standalone.append(fp) + for fp in sorted(standalone, key=lambda p: len(p.parts), reverse=True): + sign_path(fp, runtime=False) + + # 2) Nested frameworks and helper apps, deepest first. + bundles: list[Path] = [] + frameworks_dir = contents / "Frameworks" + if frameworks_dir.exists(): + for root, _dirs, _files in os.walk(frameworks_dir): + p = Path(root) + if p.suffix in {".framework", ".app"}: + bundles.append(p) + for bundle in sorted(set(bundles), key=lambda p: len(p.parts), reverse=True): + ent = ent_inherit if bundle.suffix == ".app" and "Helper" in bundle.name else None + sign_path(bundle, entitlements=ent, identifier=_desktop_macos_bundle_id(bundle)) + + # 3) The main bundle, with the app's own entitlements. + sign_path(app, entitlements=ent_main, identifier=_desktop_macos_bundle_id(app)) + subprocess.run( + [codesign, "--verify", "--deep", "--strict", str(app)], + check=True, capture_output=True, + ) + return True + + +def _desktop_macos_relaunchable_fixup( + desktop_dir: Path, + *, + publisher_signing_configured: Optional[bool] = None, +) -> bool: + """Make a locally-built macOS desktop app survive in-place self-update + without resetting the user's TCC permission grants. + + An ad-hoc-signed .app has no stable Designated Requirement, so when the + self-updater rebuilds the bundle in place (new cdhash) Gatekeeper reports + "Hermes is damaged and can't be opened" — and macOS TCC forgets every + permission the user granted (Full Disk Access, Desktop/Downloads/Documents, + Accessibility, Automation, microphone), re-prompting on every launch after + every update. + + Clear the quarantine xattrs, then re-sign with a stable identity: + ``desktop.macos_signing_identity`` (a persistent keychain cert — strongest) + when configured, else ad-hoc with identifier-pinned Designated Requirements, + preserving the repo's entitlement plists either way. No-op when a real + publisher identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY) or the + bundle already carries an intact Developer ID signature, so a properly + signed/notarized build is never clobbered. Callers that already made the + publisher-signing decision may pass it explicitly so a later dotenv load + can't reverse it. Falls back to the legacy deep ad-hoc sign if the + entitlement-preserving path fails. Best-effort: never raises. Returns True + when no work was needed or signing + strict verification succeeded. + """ + if sys.platform != "darwin": + return True + if publisher_signing_configured is None: + publisher_signing_configured = bool( + os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY") + ) + if publisher_signing_configured: + return True exe = _desktop_packaged_executable(desktop_dir) if exe is None: - return + return True # exe = .../Hermes.app/Contents/MacOS/Hermes -> app bundle = .../Hermes.app app = exe.parents[2] if not str(app).endswith(".app") or not app.is_dir(): - return + return True codesign = shutil.which("codesign") if not codesign: - return + return False + if _desktop_macos_has_valid_real_signature(app): + return True + subprocess.run(["xattr", "-cr", str(app)], check=False) + identity = _desktop_macos_local_signing_identity() or "-" + try: + if _desktop_macos_local_codesign(app, desktop_dir=desktop_dir, identity=identity): + label = "keychain identity" if identity != "-" else "stable ad-hoc identity" + print(f" → macOS desktop signed with {label}; TCC grants persist across rebuilds") + return True + except Exception as exc: + if identity != "-": + print( + f" (warning: configured macOS signing identity failed: {identity!r}; " + "falling back to ad-hoc — TCC grants may need to be re-granted)" + ) + print(f" (warning: stable macOS signing failed ({exc}); using legacy ad-hoc sign)") try: - subprocess.run(["xattr", "-cr", str(app)], check=False) subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app)], check=False) except Exception as exc: print(f" (warning: macOS relaunch fixup skipped: {exc})") + return False def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool: diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index b5fd1ab8d327..0a9653ac9c91 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -1054,6 +1054,219 @@ def test_force_adhoc_signing_respects_explicit_caller_flag(monkeypatch): assert env["CSC_IDENTITY_AUTO_DISCOVERY"] == "true" +# --- macOS TCC-stable local signing (relaunch fixup) ----------------------- + + +def _write_info_plist(bundle: Path, identifier: str) -> None: + import plistlib + + info = bundle / "Contents" / "Info.plist" + info.parent.mkdir(parents=True, exist_ok=True) + info.write_bytes(plistlib.dumps({"CFBundleIdentifier": identifier})) + + +def _make_signable_app(desktop_dir: Path) -> Path: + """Build a fake packaged Hermes.app with the pieces the signer must find.""" + ent_dir = desktop_dir / "electron" + ent_dir.mkdir(parents=True, exist_ok=True) + (ent_dir / "entitlements.mac.plist").write_text("", encoding="utf-8") + (ent_dir / "entitlements.mac.inherit.plist").write_text("", encoding="utf-8") + + app = desktop_dir / "release" / "mac-arm64" / "Hermes.app" + _write_info_plist(app, "com.nousresearch.hermes") + (app / "Contents" / "MacOS").mkdir(parents=True) + (app / "Contents" / "MacOS" / "Hermes").write_text("", encoding="utf-8") + + helper = app / "Contents" / "Frameworks" / "Hermes Helper.app" + _write_info_plist(helper, "com.nousresearch.hermes.helper") + + native_dir = app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" + native_dir.mkdir(parents=True) + (native_dir / "pty.node").write_text("", encoding="utf-8") + (app / "Contents" / "Frameworks" / "chrome_crashpad_handler").write_text("", encoding="utf-8") + return app + + +def _collect_codesign_calls(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr( + cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None + ) + monkeypatch.setattr(cli_main.subprocess, "run", fake_run) + return calls + + +def test_desktop_macos_local_codesign_signs_native_binaries(tmp_path, monkeypatch): + """The standalone Mach-O pass must actually find files inside the bundle. + + Regression: an absolute-path parts check always matches the outer + Hermes.app component, silently skipping every .node/.dylib/crashpad + binary — codesign then rejects the outer signature (nested code unsigned). + """ + desktop_dir = tmp_path / "apps" / "desktop" + app = _make_signable_app(desktop_dir) + calls = _collect_codesign_calls(monkeypatch) + + assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True + + signed = [c[-1] for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]] + assert str(app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" / "pty.node") in signed + assert str(app / "Contents" / "Frameworks" / "chrome_crashpad_handler") in signed + + +def test_desktop_macos_local_codesign_stable_requirements_and_entitlements(tmp_path, monkeypatch): + """Ad-hoc signing must not leave the bundle with a cdhash-only identity. + + TCC pins grants to the Designated Requirement; cdhash-only DRs churn on + every rebuild. Each bundle gets an explicit identifier requirement, the + main app keeps its entitlements, helpers keep the inherit entitlements, + and the whole thing is strictly verified at the end. + """ + desktop_dir = tmp_path / "apps" / "desktop" + app = _make_signable_app(desktop_dir) + ent_main = desktop_dir / "electron" / "entitlements.mac.plist" + ent_inherit = desktop_dir / "electron" / "entitlements.mac.inherit.plist" + calls = _collect_codesign_calls(monkeypatch) + + assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True + + sign_calls = [c for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]] + assert any( + '=designated => identifier "com.nousresearch.hermes"' in c and str(ent_main) in c + for c in sign_calls + ) + assert any( + '=designated => identifier "com.nousresearch.hermes.helper"' in c and str(ent_inherit) in c + for c in sign_calls + ) + assert calls[-1][:4] == ["/usr/bin/codesign", "--verify", "--deep", "--strict"] + + +def test_desktop_macos_local_codesign_keychain_identity_skips_requirements(tmp_path, monkeypatch): + """A real cert anchors the DR by itself; no explicit requirement injected.""" + desktop_dir = tmp_path / "apps" / "desktop" + app = _make_signable_app(desktop_dir) + calls = _collect_codesign_calls(monkeypatch) + + assert cli_main._desktop_macos_local_codesign( + app, desktop_dir=desktop_dir, identity="Hermes Local Signing" + ) is True + + sign_calls = [c for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]] + assert sign_calls, "expected sign invocations" + assert all(c[3] == "Hermes Local Signing" for c in sign_calls) + assert all("--requirements" not in c for c in sign_calls) + + +def test_desktop_macos_local_codesign_false_without_codesign(tmp_path, monkeypatch): + desktop_dir = tmp_path / "apps" / "desktop" + app = _make_signable_app(desktop_dir) + monkeypatch.setattr(cli_main.shutil, "which", lambda name: None) + assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is False + + +def test_relaunchable_fixup_noop_when_publisher_signing_configured(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(cli_main.sys, "platform", "darwin") + monkeypatch.setenv("CSC_LINK", "publisher-cert") + + with patch("hermes_cli.main.subprocess.run") as run: + assert cli_main._desktop_macos_relaunchable_fixup(root / "apps" / "desktop") is True + run.assert_not_called() + + +def test_relaunchable_fixup_explicit_publisher_decision_beats_environment(tmp_path, monkeypatch): + """A caller that already decided publisher signing wins over later env state.""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(cli_main.sys, "platform", "darwin") + monkeypatch.delenv("CSC_LINK", raising=False) + monkeypatch.setenv("APPLE_SIGNING_IDENTITY", "loaded-later-from-dotenv") + + with patch("hermes_cli.main.subprocess.run") as run: + assert cli_main._desktop_macos_relaunchable_fixup( + root / "apps" / "desktop", publisher_signing_configured=True + ) is True + run.assert_not_called() + + +def test_relaunchable_fixup_never_clobbers_valid_developer_id_signature(tmp_path, monkeypatch): + """A bundle with an intact Team ID signature must be left untouched.""" + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.delenv("CSC_LINK", raising=False) + monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False) + exe = _make_packaged_executable(root, monkeypatch, platform="darwin") + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + if cmd[:2] == ["/usr/bin/codesign", "-dv"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="TeamIdentifier=T2F6S8MF7C\n") + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr( + cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None + ) + monkeypatch.setattr(cli_main.subprocess, "run", fake_run) + + assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True + assert all(c[:3] != ["/usr/bin/codesign", "--force", "--sign"] for c in calls) + assert all(c[0] != "xattr" for c in calls) + + +def test_relaunchable_fixup_falls_back_to_legacy_adhoc_on_failure(tmp_path, monkeypatch, capsys): + """A failing stable sign must still leave a launchable (deep ad-hoc) bundle.""" + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.delenv("CSC_LINK", raising=False) + monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False) + exe = _make_packaged_executable(root, monkeypatch, platform="darwin") + app = exe.parents[2] + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr( + cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None + ) + monkeypatch.setattr(cli_main.subprocess, "run", fake_run) + monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None) + + def boom(*a, **kw): + raise subprocess.CalledProcessError(1, ["codesign"]) + + monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", boom) + + assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is False + assert ["xattr", "-cr", str(app)] in calls + assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls + + +def test_desktop_macos_local_signing_identity_reads_config(monkeypatch): + monkeypatch.setattr(cli_main.sys, "platform", "darwin") + with patch( + "hermes_cli.config.load_config", + return_value={"desktop": {"macos_signing_identity": " Hermes Local Signing "}}, + ): + assert cli_main._desktop_macos_local_signing_identity() == "Hermes Local Signing" + with patch("hermes_cli.config.load_config", return_value={"desktop": {}}): + assert cli_main._desktop_macos_local_signing_identity() is None + + # --- desktop.* launch options (config.yaml) ------------------------------- diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 9798204ba166..ecef012bbf44 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -775,6 +775,14 @@ class TestSchemaValidation: assert saved["approvals"]["mode"] == "off" assert "not a recognized config key" not in capsys.readouterr().out + def test_desktop_macos_signing_identity_is_accepted(self, _isolated_hermes_home, capsys): + """The documented TCC signing identity setting is part of the schema.""" + set_config_value("desktop.macos_signing_identity", "Hermes Local Signing") + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + assert saved["desktop"]["macos_signing_identity"] == "Hermes Local Signing" + assert "not a recognized config key" not in capsys.readouterr().out + def test_close_typo_suggests_correct_key(self, _isolated_hermes_home, capsys): """Typo'd top-level keys should get a fuzzy-match suggestion.""" set_config_value("disco", "false")