fix(gateway): use user launchd domain + Background session, detached fallback (macOS 26)

Salvages the primary fix from #24275 (asdlem) and layers a last-resort
fallback on top:

Primary (from #24275): the real macOS 26 root cause is that `gui/<uid>`
isn't reachable from non-Aqua/background sessions. Switch the launchd
domain to `user/<uid>` and mark the plist valid for both Aqua and
Background sessions (LimitLoadToSessionType), restoring a real supervised
service. Treat exit code 125 as "job unloaded" so start/restart
re-bootstrap and retry.

Last resort (this PR): the #23387 reporter saw `user/<uid>` bootstrap
also fail with error 5 on some hosts. When even a fresh bootstrap can't
manage the domain (codes 5/125 persist), degrade to a CLI-managed
detached background process instead of crashing — logs to gateway.log,
PID tracked via gateway.pid so stop/status/restart keep working. Print
guidance that it won't auto-start at login or auto-restart on crash.

Co-authored-by: asdlem <asdlem@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson
2026-06-06 09:08:37 -07:00
committed by Teknium
co-authored by asdlem
parent 59c273ba3a
commit 3606307339
2 changed files with 104 additions and 37 deletions
+57 -10
View File
@@ -679,17 +679,51 @@ class TestLaunchdServiceRecovery:
assert "stale" in output.lower()
assert "not loaded" in output.lower()
def test_launchd_domain_uses_user_domain(self):
# The user/<uid> domain (not gui/<uid>) is the one reachable from
# non-Aqua/background sessions on macOS 26+ (issue #23387).
assert gateway_cli._launchd_domain() == f"user/{os.getuid()}"
def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):
# macOS 26+ rejects gui/<uid> management with these codes (issue #23387).
# Codes that persist after a fresh bootstrap → launchd truly unavailable.
assert gateway_cli._launchctl_domain_unsupported(5) is True
assert gateway_cli._launchctl_domain_unsupported(125) is True
# Codes that mean "job not loaded" are NOT domain-unsupported.
assert gateway_cli._launchctl_domain_unsupported(3) is False
assert gateway_cli._launchctl_domain_unsupported(113) is False
assert gateway_cli._launchctl_domain_unsupported(0) is False
def test_launchd_start_falls_back_to_detached_on_kickstart_125(self, tmp_path, monkeypatch, capsys):
"""macOS 26 kickstart error 125 should spawn a detached gateway, not crash."""
def test_launchd_start_reloads_on_kickstart_exit_code_125(self, tmp_path, monkeypatch):
"""Exit code 125 means the job is absent from the domain → bootstrap recovery."""
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
label = gateway_cli.get_launchd_label()
calls = []
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"
def fake_run(cmd, check=False, **kwargs):
if cmd and cmd[0] == "launchctl":
calls.append(cmd)
if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1:
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
gateway_cli.launchd_start()
assert calls == [
["launchctl", "kickstart", target],
["launchctl", "bootstrap", domain, str(plist_path)],
["launchctl", "kickstart", target],
]
def test_launchd_start_falls_back_to_detached_when_rebootstrap_fails(self, tmp_path, monkeypatch, capsys):
"""If even a fresh bootstrap can't manage the domain, spawn detached."""
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
label = gateway_cli.get_launchd_label()
@@ -700,9 +734,15 @@ class TestLaunchdServiceRecovery:
def fake_run(cmd, check=False, **kwargs):
if cmd == ["launchctl", "kickstart", target]:
# First kickstart: job not loaded (125). After bootstrap also
# fails, this won't be reached again.
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
)
if cmd[:2] == ["launchctl", "bootstrap"]:
raise gateway_cli.subprocess.CalledProcessError(
5, cmd, stderr="Input/output error"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
@@ -715,11 +755,10 @@ class TestLaunchdServiceRecovery:
gateway_cli.launchd_start()
assert spawned == [True]
out = capsys.readouterr().out.lower()
assert "background process" in out
assert "background process" in capsys.readouterr().out.lower()
def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, monkeypatch, capsys):
"""macOS 26 bootstrap error 5 should spawn a detached gateway, not crash."""
"""macOS bootstrap error 5 should spawn a detached gateway, not crash."""
plist_path = tmp_path / "ai.hermes.gateway.plist"
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
@@ -742,8 +781,8 @@ class TestLaunchdServiceRecovery:
assert spawned == [True]
assert "Service installed and loaded" not in capsys.readouterr().out
def test_launchd_restart_falls_back_to_detached_on_kickstart_125(self, monkeypatch, capsys):
"""When kickstart -k returns 125, restart should relaunch detached."""
def test_launchd_restart_falls_back_to_detached_on_error_5(self, monkeypatch, capsys):
"""kickstart -k error 5 (domain unmanageable) should relaunch detached."""
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"
monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 5.0)
@@ -755,7 +794,7 @@ class TestLaunchdServiceRecovery:
def fake_run(cmd, check=False, **kwargs):
if cmd == ["launchctl", "kickstart", "-k", target]:
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
5, cmd, stderr="Input/output error"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -1746,6 +1785,14 @@ class TestProfileArg:
assert "<string>--profile</string>" in plist
assert "<string>mybot</string>" in plist
def test_launchd_plist_supports_aqua_and_background_sessions(self):
# macOS 26+ only loads the agent in non-Aqua sessions when the plist
# opts into Background as well (issue #23387).
plist = gateway_cli.generate_launchd_plist()
assert "<key>LimitLoadToSessionType</key>" in plist
assert "<string>Aqua</string>" in plist
assert "<string>Background</string>" in plist
def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
profile_dir.mkdir(parents=True)