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 ec4cdc029..68c1374c1 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -2619,7 +2619,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"