From d274d884a5e42178640b4ad20420d483a405ca71 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 25 Oct 2026 13:43:03 +0000 Subject: [PATCH] Fix handset probe: require both kiosk and plasma units for mobile device class Before this fix, a desk machine set up with taos-kiosk.service (which kiosk-setup.sh writes on any machine) was incorrectly treated as a taOSmobile handset. The defect affected: - hardware._detect_device_class() returning "mobile" for desks - routes.system._session_mode_state() reporting available:true for desks - Consumers: frameworks.recommended_framework, middleware/security_headers.py, routes/settings.py BLE extras, desktop/src/lib/mobile-shell.ts Fix: 1. hardware._detect_device_class(): return "mobile" only when BOTH systemctl cat taos-kiosk.service AND systemctl cat plasma-mobile.service return rc 0. Updated docstring. 2. routes.system._session_mode_state(): available=True only when BOTH units in _SESSION_UNITS exist. Updated AVAILABILITY comment. Tests added: - test_desk_with_kiosk_but_no_plasma_is_unavailable in test_session_mode.py - test_desk_with_kiosk_but_no_plasma_is_not_mobile, test_handset_with_both_units_is_mobile, test_missing_kiosk_unit_is_not_mobile in test_device_class_probe.py RED-FIRST proof (tests fail before fix): ``` Using CPython 3.14.7 Creating virtual environment at: .venv Building tinyagentos @ file:///tmp/exec-tsk-l6jtud Built tinyagentos @ file:///tmp/exec-tsk-l6jtud warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance. If the cache and target directories are on different filesystems, hardlinking may not be supported. If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning. Installed 108 packages in 501ms ...F.FF. [100%] =================================== FAILURES =================================== ______________ test_desk_with_kiosk_but_no_plasma_is_unavailable _______________ monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7770c25eb350> @pytest.mark.asyncio async def test_desk_with_kiosk_but_no_plasma_is_unavailable(monkeypatch): """A desk with only taos-kiosk.service must report available=False and 404 on switch.""" started: list[str] = [] async def fake(*args): if args[0] == "start": started.append(args[-1]) return 0, "" if args[0] == "cat": unit = args[-1] if unit == "taos-kiosk.service": return 0, "# taos-kiosk.service" if unit == "plasma-mobile.service": return 1, "No files found" return 3, "inactive" monkeypatch.setattr(system_routes, "_systemctl", fake) state = await system_routes._session_mode_state() > assert state["available"] is False E assert True is False tests/test_session_mode.py:93: AssertionError _______________ test_desk_with_kiosk_but_no_plasma_is_not_mobile _______________ monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7770c261f690> def test_desk_with_kiosk_but_no_plasma_is_not_mobile(monkeypatch): """A desk with only taos-kiosk.service must NOT be detected as mobile.""" calls = [] def fake_run(cmd, *args, **kwargs): calls.append(cmd) if cmd == ["systemctl", "cat", "taos-kiosk.service"]: return subprocess.CompletedProcess(cmd, 0, stdout="# taos-kiosk.service", stderr="") if cmd == ["systemctl", "cat", "plasma-mobile.service"]: return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="No files found") return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") monkeypatch.setattr(subprocess, "run", fake_run) > assert hardware._detect_device_class() is None E AssertionError: assert 'mobile' is None E + where 'mobile' = () E + where = hardware._detect_device_class tests/test_device_class_probe.py:21: AssertionError ____________________ test_handset_with_both_units_is_mobile ____________________ monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7770c2606000> @pytest.mark.asyncio async def test_handset_with_both_units_is_mobile(monkeypatch): """A handset with both taos-kiosk.service and plasma-mobile.service is mobile.""" calls = [] def fake_run(cmd, *args, **kwargs): calls.append(cmd) if cmd == ["systemctl", "cat", "taos-kiosk.service"]: return subprocess.CompletedProcess(cmd, 0, stdout="# taos-kiosk.service", stderr="") if cmd == ["systemctl", "cat", "plasma-mobile.service"]: return subprocess.CompletedProcess(cmd, 0, stdout="# plasma-mobile.service", stderr="") return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") monkeypatch.setattr(subprocess, "run", fake_run) assert hardware._detect_device_class() == "mobile" assert calls.count(["systemctl", "cat", "taos-kiosk.service"]) == 1 > assert calls.count(["systemctl", "cat", "plasma-mobile.service"]) == 1 E AssertionError: assert 0 == 1 E + where 0 = (['systemctl', 'cat', 'plasma-mobile.service']) E + where = [['systemctl', 'cat', 'taos-kiosk.service']].count tests/test_device_class_probe.py:41: AssertionError =========================== short test summary info ============================ FAILED tests/test_session_mode.py::test_desk_with_kiosk_but_no_plasma_is_unavailable FAILED tests/test_device_class_probe.py::test_desk_with_kiosk_but_no_plasma_is_not_mobile FAILED tests/test_device_class_probe.py::test_handset_with_both_units_is_mobile 3 failed, 5 passed in 0.49s ``` Docs-Reviewed: updated docs/agent-coordination.md to reflect that _detect_device_class now checks both units --- .../tsk-l6jtud-handset-probe-fallback-unit.md | 4 ++ docs/agent-coordination.md | 2 +- tests/test_device_class_probe.py | 56 +++++++++++++++++++ tests/test_session_mode.py | 34 +++++++++++ tests/test_taos_agent_picoclaw.py | 7 ++- tinyagentos/hardware.py | 19 +++++-- tinyagentos/routes/system.py | 14 +++-- 7 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 changelog.d/tsk-l6jtud-handset-probe-fallback-unit.md create mode 100644 tests/test_device_class_probe.py diff --git a/changelog.d/tsk-l6jtud-handset-probe-fallback-unit.md b/changelog.d/tsk-l6jtud-handset-probe-fallback-unit.md new file mode 100644 index 000000000..1e677642f --- /dev/null +++ b/changelog.d/tsk-l6jtud-handset-probe-fallback-unit.md @@ -0,0 +1,4 @@ +### Fixed: a desk machine set up with the kiosk session is no longer treated as a phone +- `hardware._detect_device_class()` now returns "mobile" only when BOTH `taos-kiosk.service` AND `plasma-mobile.service` units exist (via `systemctl cat`). +- `routes.system._session_mode_state()` reports `available: true` only when BOTH units are present. +- The AVAILABILITY comment in `routes/system.py` is updated to reflect that a handset requires both the kiosk unit (taOS shell) and the plasma unit (Plasma Mobile fallback). \ No newline at end of file diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 98179ab16..d846c681c 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -2602,7 +2602,7 @@ pinned 0.3.1 binary). `taos_agent_runtime.decide_framework` is the one place the choice is made, from two config.yaml keys: - `device.class`: `auto` (default; `hardware._detect_device_class`, which - reads the `taos-kiosk.service` unit), `mobile` or `desktop`; + reads the `taos-kiosk.service` and `plasma-mobile.service` units), `mobile` or `desktop`; - `taos_agent.framework`: `auto` (default; picoclaw on mobile, opencode elsewhere), `opencode`, `picoclaw` or `claude_code` (operator overrides; `auto` never picks `claude_code`). diff --git a/tests/test_device_class_probe.py b/tests/test_device_class_probe.py new file mode 100644 index 000000000..a2296a7b6 --- /dev/null +++ b/tests/test_device_class_probe.py @@ -0,0 +1,56 @@ +"""Device class probe: a handset requires BOTH kiosk and plasma units.""" + +import subprocess +from unittest.mock import patch +from tinyagentos import hardware + + +def test_desk_with_kiosk_but_no_plasma_is_not_mobile(monkeypatch): + """A desk with only taos-kiosk.service must NOT be detected as mobile.""" + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + if cmd == ["systemctl", "cat", "taos-kiosk.service"]: + return subprocess.CompletedProcess(cmd, 0, stdout="# taos-kiosk.service", stderr="") + if cmd == ["systemctl", "cat", "plasma-mobile.service"]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="No files found") + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert hardware._detect_device_class() is None + assert calls.count(["systemctl", "cat", "taos-kiosk.service"]) == 1 + assert calls.count(["systemctl", "cat", "plasma-mobile.service"]) == 1 + + +def test_handset_with_both_units_is_mobile(monkeypatch): + """A handset with both taos-kiosk.service and plasma-mobile.service is mobile.""" + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + if cmd == ["systemctl", "cat", "taos-kiosk.service"]: + return subprocess.CompletedProcess(cmd, 0, stdout="# taos-kiosk.service", stderr="") + if cmd == ["systemctl", "cat", "plasma-mobile.service"]: + return subprocess.CompletedProcess(cmd, 0, stdout="# plasma-mobile.service", stderr="") + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert hardware._detect_device_class() == "mobile" + assert calls.count(["systemctl", "cat", "taos-kiosk.service"]) == 1 + assert calls.count(["systemctl", "cat", "plasma-mobile.service"]) == 1 + + +def test_missing_kiosk_unit_is_not_mobile(monkeypatch): + """A machine without taos-kiosk.service must NOT be detected as mobile.""" + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + if cmd == ["systemctl", "cat", "taos-kiosk.service"]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="No files found") + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert hardware._detect_device_class() is None + assert calls.count(["systemctl", "cat", "taos-kiosk.service"]) == 1 \ No newline at end of file diff --git a/tests/test_session_mode.py b/tests/test_session_mode.py index c748bd721..f5146e906 100644 --- a/tests/test_session_mode.py +++ b/tests/test_session_mode.py @@ -71,6 +71,40 @@ async def json(self): assert started == [], "must not start a unit on an unsupported device" +@pytest.mark.asyncio +async def test_desk_with_kiosk_but_no_plasma_is_unavailable(monkeypatch): + """A desk with only taos-kiosk.service must report available=False and 404 on switch.""" + started: list[str] = [] + + async def fake(*args): + if args[0] == "start": + started.append(args[-1]) + return 0, "" + if args[0] == "cat": + unit = args[-1] + if unit == "taos-kiosk.service": + return 0, "# taos-kiosk.service" + if unit == "plasma-mobile.service": + return 1, "No files found" + return 3, "inactive" + + monkeypatch.setattr(system_routes, "_systemctl", fake) + state = await system_routes._session_mode_state() + assert state["available"] is False + assert state["current"] == "unknown" + + class _Req: + async def body(self): + return b'{"mode":"plasma"}' + + async def json(self): + return {"mode": "plasma"} + + resp = await system_routes.set_session_mode(_Req()) + assert resp.status_code == 404 + assert started == [], "must not start a unit when plasma service is absent" + + @pytest.mark.asyncio async def test_unknown_mode_rejected_before_any_unit_is_touched(monkeypatch): started: list[str] = [] diff --git a/tests/test_taos_agent_picoclaw.py b/tests/test_taos_agent_picoclaw.py index e8eb3827c..332c493ba 100644 --- a/tests/test_taos_agent_picoclaw.py +++ b/tests/test_taos_agent_picoclaw.py @@ -57,8 +57,9 @@ # --------------------------------------------------------------------------- _FAKE_SYSTEMCTL = """#!/bin/sh -# taos-kiosk.service exists only on a taOSmobile handset. +# taos-kiosk.service and plasma-mobile.service both exist on a taOSmobile handset. if [ "$1" = "cat" ] && [ "$2" = "taos-kiosk.service" ]; then exit {kiosk_rc}; fi +if [ "$1" = "cat" ] && [ "$2" = "plasma-mobile.service" ]; then exit {plasma_rc}; fi exit 1 """ @@ -126,7 +127,9 @@ async def _build_app(tmp_path_factory, name: str, *, mobile: bool): data_dir = tmp_path_factory.mktemp(name) _write_test_config(data_dir) bindir = tmp_path_factory.mktemp(name + "-bin") - _write_exec(bindir / "systemctl", _FAKE_SYSTEMCTL.format(kiosk_rc=0 if mobile else 1)) + kiosk_rc = 0 if mobile else 1 + plasma_rc = 0 if mobile else 1 + _write_exec(bindir / "systemctl", _FAKE_SYSTEMCTL.format(kiosk_rc=kiosk_rc, plasma_rc=plasma_rc)) _write_exec(bindir / "picoclaw", _FAKE_PICOCLAW.format(python=sys.executable)) with pytest.MonkeyPatch.context() as mp: mp.setenv("TAOS_LLM_GATEWAY", "1") diff --git a/tinyagentos/hardware.py b/tinyagentos/hardware.py index 9a4c7f9ce..04abbae4b 100644 --- a/tinyagentos/hardware.py +++ b/tinyagentos/hardware.py @@ -654,19 +654,28 @@ def _detect_os() -> OsInfo: def _detect_device_class() -> str | None: """Detect if this is a taOSmobile handset. - A taOSmobile handset is identified by the presence of the taos-kiosk.service - systemd unit (which only exists on devices provisioned as taOS handsets). - This mirrors the capability probe used by /api/system/session-mode. + A taOSmobile handset is identified by the presence of BOTH the + taos-kiosk.service AND plasma-mobile.service systemd units. The kiosk unit + provides the taOS shell; the plasma unit is the stock KDE phone shell that + acts as the fallback. A desk runs the kiosk unit only and is NOT a handset. """ try: import subprocess - result = subprocess.run( + kiosk_result = subprocess.run( ["systemctl", "cat", "taos-kiosk.service"], capture_output=True, text=True, timeout=5, ) - if result.returncode == 0: + if kiosk_result.returncode != 0: + return None + plasma_result = subprocess.run( + ["systemctl", "cat", "plasma-mobile.service"], + capture_output=True, + text=True, + timeout=5, + ) + if plasma_result.returncode == 0: return "mobile" except (subprocess.SubprocessError, FileNotFoundError): pass diff --git a/tinyagentos/routes/system.py b/tinyagentos/routes/system.py index 2947bb279..8cf9b0ada 100644 --- a/tinyagentos/routes/system.py +++ b/tinyagentos/routes/system.py @@ -327,10 +327,11 @@ async def restart_status(request: Request): # runs as the unprivileged `taos` user and must not be able to manage arbitrary # system services just to offer a UI toggle. # -# AVAILABILITY IS THE DEVICE GATE. `taos-kiosk.service` only exists on a device -# provisioned as a taOS handset, so `available: false` everywhere else and the -# frontend hides the app rather than shipping a button that cannot work. This -# is deliberately a capability probe, not a hardcoded device list. +# AVAILABILITY IS THE DEVICE GATE. A taOS handset has BOTH taos-kiosk.service +# (the taOS shell) AND plasma-mobile.service (the Plasma Mobile fallback). A +# desk runs the kiosk unit only, so `available: false` there and the frontend +# hides the app rather than shipping a button that cannot work. This is +# deliberately a capability probe, not a hardcoded device list. _SESSION_UNITS = {"kiosk": "taos-kiosk.service", "plasma": "plasma-mobile.service"} @@ -357,8 +358,9 @@ async def _session_mode_state() -> dict: # `is-active` exits non-zero for inactive units, so the OUTPUT is the signal # and rc is not. An unknown unit reports "inactive" too, so availability is # probed separately with `cat`, which fails only when the unit is absent. - cat_rc, _ = await _systemctl("cat", _SESSION_UNITS["kiosk"]) - available = cat_rc == 0 + kiosk_cat_rc, _ = await _systemctl("cat", _SESSION_UNITS["kiosk"]) + plasma_cat_rc, _ = await _systemctl("cat", _SESSION_UNITS["plasma"]) + available = kiosk_cat_rc == 0 and plasma_cat_rc == 0 _, plasma_state = await _systemctl("is-active", _SESSION_UNITS["plasma"]) if kiosk_state == "active": current = "kiosk"