diff --git a/skills/.manifest.json b/skills/.manifest.json index a76ba8d..1e32d5d 100644 --- a/skills/.manifest.json +++ b/skills/.manifest.json @@ -61,7 +61,7 @@ "notes.py": "7d50d1544ea955f59917a1f0e7902d115e9dbccd3188b641057a55b6a5b2803a", "notification_reader.py": "681208ae4253dfe549512cce4c722c76ca85f2bbbdb8737e37c026ec9444c972", "password_generator.py": "f11a917299e14cbd2560111da0bb748cd08792cf715cc4098c64eb62da8c54e3", - "philips_hue.py": "ac2b8c1418a04a14025032ce6f5b820ab4063589b4473e63f88c9c24e6a21e13", + "philips_hue.py": "fa831712c39dc6327c84199d8f0aeb09a169a264ce3d936cc337a5c5d6632f7e", "pilot.py": "ded952504f8d44c3c6ea5b1da1ef2f09f0bbf1b771e2ad9dd90db6cd1701c3fb", "plugin_approve.py": "c93861353be2a9ebe08df212ca167bea646962dbeafe704b1864cd78a8cf57cc", "pm2_control.py": "53d34a9ddb7689b86f192694d6ccc18b154538626cbc72992445d0b74f2e5662", diff --git a/skills/philips_hue.py b/skills/philips_hue.py index 60875d4..836dda3 100644 --- a/skills/philips_hue.py +++ b/skills/philips_hue.py @@ -114,22 +114,86 @@ def _api_url(ip, user, path=""): return f"http://{ip}/api/{user}{path}" +def _launchctl_request(method, url, body=None): + """Run a bridge request OUTSIDE the pm2/node process tree via + `launchctl asuser`, so it executes in the GUI login session (which has + Local-Network access) instead of the pm2 tree (which macOS denies a LAN + route → '[Errno 65] No route to host'). Proven: pm2-tree processes can't + reach the bridge; launchctl-asuser ones can. Used only as a fallback after + the in-process request fails with ConnectionError.""" + import os as _os + import subprocess as _sp + import sys as _sys + import time as _t + out = "/tmp/.codec_hue_%d_%d.json" % (_os.getpid(), int(_t.time() * 1000) % 1000000) + code = ( + "import requests,sys,json\n" + "m,u,b,o=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]\n" + "bd=json.loads(b) if b else None\n" + "try:\n" + " r=requests.put(u,json=bd,timeout=5) if m=='PUT' else requests.get(u,timeout=5)\n" + " r.raise_for_status(); open(o,'w').write(r.text or '[]')\n" + "except Exception as e: open(o,'w').write('HUE_ERR:'+str(e))\n" + ) + payload = json.dumps(body) if body is not None else "" + # Use a python whose *launch path* has Local-Network access. The dashboard's + # sys.executable is the raw Cellar binary, which macOS treats as a separate + # (ungranted) identity; the symlinked paths below are the granted "Python". + py = _sys.executable + for _cand in ("/usr/local/bin/python3.13", "/opt/homebrew/bin/python3.13", + "/usr/local/bin/python3", "/opt/homebrew/bin/python3"): + if _os.path.exists(_cand): + py = _cand + break + try: + _os.remove(out) + except OSError: + pass + try: + _sp.run(["launchctl", "asuser", str(_os.getuid()), py, "-c", + code, method, url, payload, out], + timeout=15, capture_output=True) + except Exception as exc: + raise requests.ConnectionError("hue launchctl relay failed: %s" % exc) + data = None + for _ in range(40): # launchctl asuser is async — poll for the result file + try: + with open(out) as fh: + data = fh.read() + break + except OSError: + _t.sleep(0.2) + try: + _os.remove(out) + except OSError: + pass + if data is None: + raise requests.ConnectionError("hue relay timed out") + if data.startswith("HUE_ERR:"): + raise requests.ConnectionError(data[8:][:160]) + return json.loads(data) if data.strip() else [] + + def _get(ip, user, path=""): """GET from the bridge. Returns parsed JSON or raises.""" - resp = requests.get(_api_url(ip, user, path), timeout=REQUEST_TIMEOUT) - resp.raise_for_status() - return resp.json() + url = _api_url(ip, user, path) + try: + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + return resp.json() + except requests.ConnectionError: + return _launchctl_request("GET", url) def _put(ip, user, path, body): """PUT to the bridge. Returns parsed JSON or raises.""" - resp = requests.put( - _api_url(ip, user, path), - json=body, - timeout=REQUEST_TIMEOUT, - ) - resp.raise_for_status() - return resp.json() + url = _api_url(ip, user, path) + try: + resp = requests.put(url, json=body, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + return resp.json() + except requests.ConnectionError: + return _launchctl_request("PUT", url, body) def _find_light_by_name(lights, name): diff --git a/tests/test_philips_hue.py b/tests/test_philips_hue.py index fe5f2de..8ea4f76 100644 --- a/tests/test_philips_hue.py +++ b/tests/test_philips_hue.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "skills")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import pytest # noqa: E402 import requests # noqa: E402 import codec_hue_discovery # noqa: E402 @@ -103,3 +104,98 @@ def dead(ip, user, ttype, tid, state): out = philips_hue.run("lights off") assert "Could not reach Hue Bridge" in out + + +# ── launchctl relay: escape the macOS Local-Network block on the pm2 tree ──── +# Regression (2026-06-09): macOS Sequoia's Local Network privacy denies the +# pm2/node process tree a LAN route to the Hue bridge ('[Errno 65] No route to +# host') while the internet still works, and there is no grantable entry for +# the python-under-node identity. The fix routes the bridge call OUTSIDE the +# pm2 tree via `launchctl asuser`, which runs in the GUI login session (which +# HAS Local-Network access) — using a python whose *launch path* is granted +# (e.g. /usr/local/bin/python3.13), NOT the dashboard's raw Cellar +# sys.executable (a separate, ungranted identity — the final-mile bug). +def test_get_falls_back_to_launchctl_relay_on_connection_error(monkeypatch): + def boom(*a, **k): + raise requests.ConnectionError("[Errno 65] No route to host") + + monkeypatch.setattr(requests, "get", boom) + sentinel = [{"state": {"on": True}}] + captured = {} + + def fake_relay(method, url, body=None): + captured.update(method=method, url=url, body=body) + return sentinel + + monkeypatch.setattr(philips_hue, "_launchctl_request", fake_relay) + out = philips_hue._get("192.168.1.81", "user", "/lights") + assert out is sentinel # did NOT propagate the ConnectionError + assert captured["method"] == "GET" + assert "192.168.1.81" in captured["url"] + + +def test_put_falls_back_to_launchctl_relay_on_connection_error(monkeypatch): + def boom(*a, **k): + raise requests.ConnectionError("[Errno 65] No route to host") + + monkeypatch.setattr(requests, "put", boom) + captured = {} + + def fake_relay(method, url, body=None): + captured.update(method=method, url=url, body=body) + return [{"success": {}}] + + monkeypatch.setattr(philips_hue, "_launchctl_request", fake_relay) + body = {"on": False} + out = philips_hue._put("192.168.1.81", "user", "/groups/0/action", body) + assert out == [{"success": {}}] + assert captured["method"] == "PUT" + assert captured["body"] == body # the PUT body is relayed through + + +def test_relay_prefers_granted_python_over_sys_executable(monkeypatch): + """The final-mile fix: the relay must launch via a Local-Network-granted + python path, not the raw Cellar sys.executable.""" + import subprocess + + real_exists = os.path.exists + granted = "/usr/local/bin/python3.13" + others = ("/opt/homebrew/bin/python3.13", "/usr/local/bin/python3", + "/opt/homebrew/bin/python3") + + def fake_exists(p): + if p == granted: + return True + if p in others: + return False + return real_exists(p) # don't disturb pytest internals + + monkeypatch.setattr(os.path, "exists", fake_exists) + captured = {} + + def fake_run(argv, **kw): + captured["argv"] = argv + with open(argv[-1], "w") as fh: # emulate the relay child writing its result file + fh.write("[]") + return subprocess.CompletedProcess(argv, 0, b"", b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + philips_hue._launchctl_request("GET", "http://192.168.1.81/api/u/lights") + + argv = captured["argv"] + # argv = [launchctl, asuser, , , -c, code, method, url, payload, out] + assert argv[:3] == ["launchctl", "asuser", str(os.getuid())] + assert argv[3] == granted, f"relay launched ungranted python: {argv[3]}" + + +def test_relay_propagates_bridge_error_as_connection_error(monkeypatch): + import subprocess + + def fake_run(argv, **kw): + with open(argv[-1], "w") as fh: + fh.write("HUE_ERR:bridge unreachable") + return subprocess.CompletedProcess(argv, 0, b"", b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + with pytest.raises(requests.ConnectionError): + philips_hue._launchctl_request("GET", "http://192.168.1.81/api/u/lights")