mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73681 from NousResearch/bb/macos-tcc-identity
fix(desktop): stable macOS signing identity so TCC grants survive local rebuilds
This commit is contained in:
commit
7bfbfa3e34
6 changed files with 540 additions and 29 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("<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_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) -------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue