fix(desktop): stable macOS signing identity so TCC grants survive rebuilds

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 <lewis4x4@users.noreply.github.com>
Co-authored-by: natebransc <natebransc@users.noreply.github.com>
Co-authored-by: caseyanthony <caseyanthony@users.noreply.github.com>
Co-authored-by: gvago <gvago@users.noreply.github.com>
Co-authored-by: twe-cloud <twe-cloud@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-28 17:28:32 -05:00
parent f9d7bca252
commit 5d171ffbbe
4 changed files with 453 additions and 20 deletions

View file

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

View file

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

View file

@ -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("<plist/>", encoding="utf-8")
(ent_dir / "entitlements.mac.inherit.plist").write_text("<plist/>", 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) -------------------------------

View file

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