Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 74 additions & 10 deletions skills/philips_hue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
96 changes: 96 additions & 0 deletions tests/test_philips_hue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, <uid>, <python>, -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")