diff --git a/tests/tools/test_computer_use_doctor_stale_units.py b/tests/tools/test_computer_use_doctor_stale_units.py new file mode 100644 index 0000000000..15c7bbc82c --- /dev/null +++ b/tests/tools/test_computer_use_doctor_stale_units.py @@ -0,0 +1,126 @@ +"""Linux daemon-unit references to pruned cua-driver release dirs (#114748). + +Linux has no managed cua-driver autostart, so users hand-write systemd user +units / XDG autostart entries against a concrete release directory. The +installer prunes all but the last five, so a versioned Exec reference +crash-loops with 203/EXEC after every upgrade — for days, while every +binary-level check (and `computer-use install`'s binary repair) stays green. +The doctor guard surfaces the dead reference with the `packages/current` +recovery hint. +""" + +import sys + +import pytest + +from tools.computer_use import doctor + + +def _write_unit(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_pruned_release_reference_is_reported(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + _write_unit( + tmp_path / "systemd" / "user" / "cua-driver-screenshot.service", + "[Service]\n" + "ExecStart=%h/.cua-driver/packages/releases/0.20.0-x86_64-unknown-linux-gnu/cua-driver " + "serve --socket %h/.cache/cua-driver/cua-driver.sock\n", + ) + findings = doctor._stale_cua_exec_references(str(tmp_path)) + assert findings == [ + ( + "systemd user unit", + "cua-driver-screenshot.service", + "%h/.cua-driver/packages/releases/0.20.0-x86_64-unknown-linux-gnu/cua-driver", + ) + ] + + +def test_current_reference_and_live_release_are_silent(tmp_path): + # `packages/current` never carries a version, so it can never rot. + _write_unit( + tmp_path / "systemd" / "user" / "cua.service", + "[Service]\nExecStart=~/.cua-driver/packages/current/cua-driver serve\n", + ) + # A releases/ dir that still exists on disk is healthy. + live = tmp_path / "live" / "packages" / "releases" / "0.28.2" / "cua-driver" + _write_unit(live, "") + _write_unit(tmp_path / "autostart" / "cua-driver.desktop", f"[Desktop Entry]\nExec={live}\n") + assert doctor._stale_cua_exec_references(str(tmp_path)) == [] + + +def test_desktop_entry_dead_release_reference(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + _write_unit( + tmp_path / "autostart" / "cua-driver.desktop", + "[Desktop Entry]\nExec=~/.cua-driver/packages/releases/0.19.0/cua-driver serve\n", + ) + assert [f[1] for f in doctor._stale_cua_exec_references(str(tmp_path))] == ["cua-driver.desktop"] + + +def test_systemd_prefix_modifier_does_not_hide_dead_target(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + # '-' means "restart failures are tolerated" — the target is still dead. + _write_unit( + tmp_path / "systemd" / "user" / "restart-anyway.service", + "[Service]\nExecStart=-%h/.cua-driver/packages/releases/0.20.0/cua-driver serve\n", + ) + assert len(doctor._stale_cua_exec_references(str(tmp_path))) == 1 + + +def test_missing_config_dirs_are_silent(tmp_path): + assert doctor._stale_cua_exec_references(str(tmp_path / "nonexistent")) == [] + + +def test_guard_appends_fail_check_and_degrades_ok(monkeypatch): + monkeypatch.setattr( + doctor, + "_stale_cua_exec_references", + lambda: [ + ( + "systemd user unit", + "cua-driver-screenshot.service", + "%h/.cua-driver/packages/releases/0.20.0-x86_64-unknown-linux-gnu/cua-driver", + ) + ], + ) + monkeypatch.setattr(sys, "platform", "linux") + out = doctor._apply_stale_unit_guard( + {"overall": "ok", "checks": [{"name": "binary_version", "status": "pass"}]} + ) + assert out["overall"] == "degraded" + appended = out["checks"][-1] + assert appended["status"] == "fail" + assert "cua-driver-screenshot.service" in appended["name"] + assert "pruned" in appended["message"] + assert "packages/current" in appended["hint"] + + +def test_guard_never_softens_worse_overall(monkeypatch): + monkeypatch.setattr( + doctor, "_stale_cua_exec_references", lambda: [("XDG autostart entry", "cua.desktop", "/x/packages/releases/0.1.0/cua-driver")] + ) + monkeypatch.setattr(sys, "platform", "linux") + out = doctor._apply_stale_unit_guard({"overall": "failed", "checks": []}) + assert out["overall"] == "failed" + assert out["checks"][-1]["status"] == "fail" + + +def test_guard_without_checks_list_appends_nothing(monkeypatch): + monkeypatch.setattr( + doctor, "_stale_cua_exec_references", lambda: [("systemd user unit", "cua.service", "/x/packages/releases/0.1.0/cua-driver")] + ) + monkeypatch.setattr(sys, "platform", "linux") + # No checks list to append to → no unexplained degraded either. + out = doctor._apply_stale_unit_guard({"overall": "ok"}) + assert out == {"overall": "ok"} + + +def test_guard_off_linux_never_scans(monkeypatch): + monkeypatch.setattr(doctor, "_stale_cua_exec_references", lambda: pytest.fail("must not scan off Linux")) + monkeypatch.setattr(sys, "platform", "darwin") + report = {"overall": "ok", "checks": []} + assert doctor._apply_stale_unit_guard(report) is report diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index 6c2fa7ddcc..ebe3dc596c 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -27,6 +27,9 @@ _ZERO_DISPLAY_MSG = "ScreenCaptureKit reachable but 0 shareable display(s) — e _ZERO_DISPLAY_HINT = ("Wake the built-in display, connect a monitor or HDMI dummy dongle (e.g. Headless Ghost), or enable " "a virtual display (Screen Sharing/VNC, BetterDisplay). Verify with `system_profiler SPDisplaysDataType`.") _IO_EXC = (OSError, subprocess.TimeoutExpired) +_PRUNED_UNIT_MSG = "Exec targets {target} which no longer exists — a cua-driver upgrade pruned that release directory" +_PRUNED_UNIT_HINT = ("Point the {kind} at ~/.cua-driver/packages/current/cua-driver — `current` survives upgrades; " + "versioned packages/releases// dirs are pruned (only the last 5 are kept)") Report = Dict[str, Any] _Row = Tuple[str, str, Report] # (status, message, extra {hint?, data?}) for one check @@ -281,6 +284,57 @@ def _apply_display_count_guard(report: Report) -> Report: report["overall"] = "degraded" return report +def _stale_cua_exec_references(config_dir: Optional[str] = None) -> List[Tuple[str, str, str]]: + """(kind, unit, target) for systemd user units / XDG autostart entries whose cua-driver Exec points at a + pruned ``packages/releases//`` directory. + + Linux has no managed cua-driver autostart (Windows-only), so daemon units are hand-written against a concrete + release dir. The installer prunes all but the last five, so a versioned reference crash-loops with 203/EXEC + after every upgrade while every binary-level check stays green (#114748). ``packages/current`` and + still-present release dirs are healthy by construction and never reported. + """ + home = os.path.expanduser("~") + base = config_dir or os.path.join(home, ".config") + findings: List[Tuple[str, str, str]] = [] + sources = (("systemd user unit", os.path.join(base, "systemd", "user"), ".service", "ExecStart"), + ("XDG autostart entry", os.path.join(base, "autostart"), ".desktop", "Exec")) + for kind, directory, suffix, key in sources: + try: + names = sorted(e.name for e in os.scandir(directory) if e.name.endswith(suffix) and e.is_file()) + except OSError: + continue + for name in names: + try: + with open(os.path.join(directory, name), encoding="utf-8", errors="replace") as fh: + text = fh.read() + except OSError: + continue + for line in text.splitlines(): + stripped = line.strip() + tokens = stripped.split("=", 1)[1].split() if stripped.startswith(key + "=") else [] + if not tokens: + continue + # first token = executable; systemd's prefix modifiers (-, +, !, :) never start a path + target = tokens[0].lstrip("-+:!") + resolved = os.path.expanduser(target.replace("%h", home)) + if "/packages/releases/" in resolved and not os.path.exists(resolved): + findings.append((kind, name, target)) + return findings + +def _apply_stale_unit_guard(report: Report) -> Report: + """Append a fail check per hand-written unit/autostart entry whose cua-driver Exec target was pruned — the + daemon can crash-loop (203/EXEC) for days while every binary-level check stays green (#114748). Binary-level + repair (`install`) cannot fix a stale unit reference, so doctor is the only surface that can name it.""" + for kind, unit, target in _stale_cua_exec_references() if sys.platform == "linux" else (): + checks = report.get("checks") + if isinstance(checks, list): + checks.append({"name": f"daemon unit ({unit})", "status": "fail", + "message": _PRUNED_UNIT_MSG.format(target=target), + "hint": _PRUNED_UNIT_HINT.format(kind=kind)}) + if report.get("overall") == "ok": # fail-worse overalls are never softened + report["overall"] = "degraded" + return report + def _wayland_environment_context(report: Report) -> Optional[Report]: """Linux+Wayland only: doctor probes the CLI process's environment, not the gateway's.""" if report.get("platform") != "linux" or not os.environ.get("WAYLAND_DISPLAY"): @@ -352,6 +406,7 @@ def run_doctor(driver_cmd: Optional[str] = None, *, include: Sequence[str] = (), print(f"cua-driver health_report failed: {e}", file=sys.stderr) return 2 report = _apply_display_count_guard(report) + report = _apply_stale_unit_guard(report) identity = _build_identity(binary, report) environment = _wayland_environment_context(report) if json_output: