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..1b49aabd5033 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -6365,41 +6365,253 @@ 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"
+ if not (ent_main.exists() and ent_inherit.exists()):
+ # Hardened-runtime restrictions are enforced even for ad-hoc
+ # signatures. Signing with --options runtime but WITHOUT the allow-jit
+ # entitlements would leave Electron/V8 crashing on launch — strictly
+ # worse than the legacy plain ad-hoc sign. Bail out so the caller
+ # falls back to that legacy path instead.
+ raise FileNotFoundError(
+ f"desktop entitlement plists missing under {desktop_dir / 'electron'}"
+ )
+
+ 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/scripts/install.sh b/scripts/install.sh
index 4449fb9151f1..53daa95ddc31 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -2990,16 +2990,41 @@ install_desktop() {
fi
fi
- # macOS: make the locally-built (ad-hoc) app relaunchable after an in-place
- # self-update. An ad-hoc bundle has no stable Designated Requirement, so a
- # later in-place rebuild (new cdhash) plus the inherited quarantine flag
- # trips Gatekeeper's tamper check ("Hermes is damaged and can't be opened").
- # Strip quarantine + re-apply a clean deep ad-hoc signature (no
- # hardened-runtime flag, which an ad-hoc build can't satisfy). Skipped when a
- # real signing identity is configured so a signed build isn't clobbered.
+ # macOS: route through the same config-aware signing fixup as
+ # `hermes desktop`, so install/repair and self-update agree about the app's
+ # identity. The fixup preserves the Electron entitlement plists and signs
+ # with a stable Designated Requirement (configured keychain identity, else
+ # identifier-pinned ad-hoc), so macOS TCC grants — Full Disk Access,
+ # Desktop/Downloads/Documents, Accessibility, microphone — survive the
+ # rebuild instead of resetting on every update. The shell's
+ # publisher-signing decision governed the build and is passed explicitly so
+ # importing Python cannot reverse it by loading HERMES_HOME/.env. If the
+ # helper is unavailable or fails, branch into the historical quarantine
+ # strip + deep ad-hoc repair so a broken venv never leaves the bundle
+ # unsigned/unlaunchable.
if [ "$OS" = "macos" ] && [ -z "${CSC_LINK:-}" ] && [ -z "${APPLE_SIGNING_IDENTITY:-}" ] && command -v codesign >/dev/null 2>&1; then
- xattr -cr "$app" 2>/dev/null || true
- codesign --force --deep --sign - "$app" >/dev/null 2>&1 || true
+ local config_python="$INSTALL_DIR/venv/bin/python"
+ local fixup_ok=""
+ if [ -x "$config_python" ]; then
+ if HERMES_HOME="$HERMES_HOME" "$config_python" - "$desktop_dir" <<'PYEOF'
+import sys
+from pathlib import Path
+from hermes_cli.main import _desktop_macos_relaunchable_fixup
+ok = _desktop_macos_relaunchable_fixup(
+ Path(sys.argv[1]), publisher_signing_configured=False
+)
+sys.exit(0 if ok else 1)
+PYEOF
+ then
+ fixup_ok=1
+ else
+ log_warn "Config-aware macOS signing fixup failed; applying the historical ad-hoc fallback."
+ fi
+ fi
+ if [ -z "$fixup_ok" ]; then
+ xattr -cr "$app" 2>/dev/null || true
+ codesign --force --deep --sign - "$app" >/dev/null 2>&1 || true
+ fi
fi
# `npm install` + `npm run pack` rewrite lockfiles; restore them so the
diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py
index b5fd1ab8d327..9c3f9d64c2fc 100644
--- a/tests/hermes_cli/test_gui_command.py
+++ b/tests/hermes_cli/test_gui_command.py
@@ -1054,6 +1054,237 @@ 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_desktop_macos_local_codesign_refuses_without_entitlements(tmp_path, monkeypatch):
+ """Hardened runtime without allow-jit bricks Electron — must raise, not sign.
+
+ Hardened-runtime restrictions are enforced even for ad-hoc signatures, so
+ signing with --options runtime while the entitlement plists are missing
+ would produce a bundle that crashes on launch. The fixup catches the raise
+ and falls back to the legacy plain ad-hoc sign instead.
+ """
+ desktop_dir = tmp_path / "apps" / "desktop"
+ app = _make_signable_app(desktop_dir)
+ (desktop_dir / "electron" / "entitlements.mac.plist").unlink()
+ calls = _collect_codesign_calls(monkeypatch)
+
+ with pytest.raises(FileNotFoundError):
+ cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir)
+ assert calls == [] # nothing was signed with a runtime flag sans entitlements
+
+
+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")
diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md
index 34ccf2a810bd..bb85133c780b 100644
--- a/website/docs/user-guide/desktop.md
+++ b/website/docs/user-guide/desktop.md
@@ -318,6 +318,32 @@ npm run pack # unpacked app under release/ (no installer)
macOS/Windows signing and notarization run automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
+### macOS permissions and local rebuilds (TCC)
+
+macOS remembers permission grants (Full Disk Access, Desktop/Downloads/Documents,
+Accessibility, Automation, microphone) against the app's *code-signing identity*,
+not its path. Locally built and self-updated apps are signed with a stable
+identifier-pinned ad-hoc signature, so grants persist across updates out of the
+box.
+
+For the strongest guarantee — a certificate-anchored identity, the same
+mechanism yabai/skhd users rely on — create a self-signed code-signing
+certificate once and tell Hermes to use it:
+
+1. Keychain Access → Certificate Assistant → **Create a Certificate…**
+2. Name: `Hermes Local Signing`, Identity Type: *Self-Signed Root*,
+ Certificate Type: **Code Signing**.
+3. `hermes config set desktop.macos_signing_identity "Hermes Local Signing"`
+
+The next update re-signs the rebuilt app with that certificate; every TCC grant
+survives. No Apple Developer account is required. Notarized release builds are
+detected and never re-signed.
+
+One-time note: changing the signing identity (including the first update after
+this fix) changes the app's identity once, so macOS will re-prompt one final
+time. Grants are stable from then on. If a permission gets stuck, reset it with
+`tccutil reset All com.nousresearch.hermes` and re-grant.
+
## See also
- [CLI Guide](./cli.md) — the terminal interface