diff --git a/muxplex/service.py b/muxplex/service.py index b3674d1..f5992cf 100644 --- a/muxplex/service.py +++ b/muxplex/service.py @@ -44,8 +44,7 @@ _LAUNCHD_PLIST_TEMPLATE = """\ {label} ProgramArguments - {muxplex_bin} - serve +{program_arguments_xml} EnvironmentVariables @@ -91,6 +90,33 @@ def _resolve_muxplex_bin() -> str: return f"{sys.executable} -m muxplex" +def _resolve_muxplex_bin_for_launchd() -> list[str]: + """Return the argv token list for the muxplex binary in a launchd plist. + + Uses Option A: prefer ``~/.local/bin/muxplex`` (stable uv-tool + console-script symlink that survives ``uv tool reinstall``). Falls back + to ``shutil.which("muxplex")``, then to ``[sys.executable, "-m", + "muxplex"]`` as explicitly split tokens. + + Each element must become its own ```` in ProgramArguments. + launchd does **not** shell-split inside a ````; an element like + ``"python3 -m muxplex"`` is treated as a literal executable name, causing + the daemon to silently fail to start. + """ + # Option A: stable console-script symlink installed by `uv tool` + local_bin = Path.home() / ".local" / "bin" / "muxplex" + if local_bin.exists() and os.access(str(local_bin), os.X_OK): + return [str(local_bin)] + + # Fall back to PATH lookup + which = shutil.which("muxplex") + if which: + return [which] + + # Last resort: explicit python -m invocation — correctly split into tokens + return [sys.executable, "-m", "muxplex"] + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -184,11 +210,20 @@ def _systemd_logs() -> None: def _launchd_install() -> None: - muxplex_bin = _resolve_muxplex_bin() + bin_args = _resolve_muxplex_bin_for_launchd() + argv = bin_args + ["serve"] + # Each argv token is its own element. launchd does NOT + # shell-split inside a , so we must NOT put the whole command + # (e.g. "python3 -m muxplex") into a single element. + program_arguments_xml = "\n".join( + f" {arg}" for arg in argv + ) base_path = os.environ.get("PATH", "/usr/bin:/bin") safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}" plist_content = _LAUNCHD_PLIST_TEMPLATE.format( - label=_LAUNCHD_LABEL, muxplex_bin=muxplex_bin, safe_path=safe_path + label=_LAUNCHD_LABEL, + program_arguments_xml=program_arguments_xml, + safe_path=safe_path, ) _LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True) _LAUNCHD_PLIST_PATH.write_text(plist_content) diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py index 790d047..89b7298 100644 --- a/muxplex/tests/test_service.py +++ b/muxplex/tests/test_service.py @@ -667,3 +667,51 @@ def test_service_install_hides_tls_tip_on_localhost(capsys, tmp_path, monkeypatc assert "muxplex setup-tls" not in out, ( f"TLS tip must NOT appear in service install output when host is 127.0.0.1, got: {out!r}" ) + + +# --------------------------------------------------------------------------- +# v0.6.7 fix — launchd plist ProgramArguments must use separate tokens +# --------------------------------------------------------------------------- + + +def test_launchd_plist_program_arguments_are_separate_strings(monkeypatch, tmp_path): + """_launchd_install emits each argv token as its own in ProgramArguments. + + The v0.6.6 bug: a single containing e.g. + "python3 -m muxplex" caused launchd to look for a literal executable + named "python3 -m muxplex" (with spaces) — which doesn't exist — so the + daemon silently failed to start on every boot. + """ + import os + import plistlib + + import muxplex.service as svc + + plist_dir = tmp_path / "LaunchAgents" + plist_path = plist_dir / "com.muxplex.plist" + + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir) + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path) + monkeypatch.setattr(os, "getuid", lambda: 501) + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: None) + monkeypatch.setattr(svc, "_prompt_host_if_localhost", lambda: None) + monkeypatch.setattr(svc, "_show_tls_nudge_if_needed", lambda: None) + + svc._launchd_install() + + assert plist_path.exists(), "plist file must be written by _launchd_install" + + plist_data = plistlib.loads(plist_path.read_bytes()) + prog_args = plist_data.get("ProgramArguments", []) + + assert len(prog_args) >= 2, ( + f"ProgramArguments must have at least 2 elements, got: {prog_args!r}" + ) + assert prog_args[-1] == "serve", ( + f"Last ProgramArguments element must be 'serve', got: {prog_args!r}" + ) + for arg in prog_args: + assert " " not in arg, ( + f"ProgramArguments element must not contain spaces " + f"(embedded-space arg trap): {arg!r} in {prog_args!r}" + )