From d08421d2b274532ca22e40321ca253472628481e Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Sat, 22 Aug 2026 14:14:58 -0400 Subject: [PATCH] feat: add installer-first onboarding and pre-download Linux browser checks Make the first run survive the five most common fresh-machine failures: - Add scripts/install.sh (ported from the openadapt.ai installer pattern): installs uv if needed, provisions Python 3.12 (managed interpreter only when no suitable system Python exists), installs 'openadapt[browser]' without any shell quoting on the user's side, and ends with an environment check (OS / Python / command / browser status) plus the next command. POSIX sh; macOS + Linux. - README quickstart becomes installer-first; the pip two-command path stays as the fallback directly below, with a short Requirements line (Python 3.10-3.12). - _browser_setup.py now probes Chromium's shared libraries via ctypes.util.find_library before downloading on Linux; when libraries are missing it prints the exact remedy first ('sudo python -m playwright install-deps chromium' plus the apt alternative) and aborts cleanly, so fresh machines no longer waste a full download on a browser that cannot launch. - The offline/CDN-blocked install error now names the manual command, the HTTPS_PROXY hint, the cache-copy fallback, and the OPENADAPT_FLOW_NO_AUTO_INSTALL opt-out. requires-python bounds are unchanged. --- README.md | 15 ++++- openadapt_flow/_browser_setup.py | 108 ++++++++++++++++++++++++++++--- scripts/install.sh | 69 ++++++++++++++++++++ tests/test_browser_setup.py | 90 +++++++++++++++++++++++++- 4 files changed, 269 insertions(+), 13 deletions(-) create mode 100755 scripts/install.sh diff --git a/README.md b/README.md index 56b3d828..e6767009 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,17 @@ rounds against the wrong-target check. ## Try it The canonical first run uses the [OpenAdapt](https://github.com/OpenAdaptAI/openadapt) -launcher: +launcher. The installer handles Python versions, virtual environments, and +shell quoting for you: + +```bash +curl -fsSL https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/main/scripts/install.sh | sh + +openadapt quickstart # the whole loop, VERIFIED +``` + +Prefer plain pip? Two commands (quote the brackets; on Windows `cmd.exe` use +double quotes: `pip install "openadapt[browser]"`): ```bash pip install 'openadapt[browser]' @@ -50,7 +60,8 @@ pip install 'openadapt[browser]' openadapt quickstart # the whole loop, VERIFIED ``` -On Windows `cmd.exe`, use double quotes: `pip install "openadapt[browser]"`. +**Requirements:** Python 3.10–3.12 (3.13+ is not yet supported; the installer +provisions a suitable interpreter for you). To work against this engine directly, install it and run the same loop under its engine-native name: diff --git a/openadapt_flow/_browser_setup.py b/openadapt_flow/_browser_setup.py index 17d5b097..ad439e8f 100644 --- a/openadapt_flow/_browser_setup.py +++ b/openadapt_flow/_browser_setup.py @@ -16,6 +16,10 @@ * **Idempotent across processes.** ``playwright install chromium`` is itself idempotent, and the probe skips it entirely once the binary is present, so a second *run* finds it installed and pays nothing. +* **No wasted downloads on fresh Linux machines.** Before downloading on + Linux, a cheap probe checks for the shared libraries Chromium needs; when + any are missing, the exact remedy is printed and the launch aborts cleanly + instead of downloading a browser that could not start anyway. * **Opt-out for air-gapped / pre-provisioned environments.** Set ``OPENADAPT_FLOW_NO_AUTO_INSTALL=1`` to skip the auto-install; the original clear Playwright "Executable doesn't exist ... run playwright install" error @@ -24,6 +28,7 @@ from __future__ import annotations +import ctypes.util import importlib.util import os import re @@ -38,6 +43,29 @@ _NOTICE = "Downloading the Chromium browser OpenAdapt needs (first run only)…" +#: Shared-library soname bases Playwright's Chromium needs at launch time on +#: Linux. These mirror the packages ``playwright install-deps chromium`` +#: installs (NSS, ATK, X11 helpers, audio, GBM, …). Names are the +#: ``ctypes.util.find_library`` form: no ``lib`` prefix, no version suffix. +_LINUX_CHROMIUM_SONAMES = ( + "nss3", + "nspr4", + "atk-1.0", + "atk-bridge-2.0", + "atspi", + "cups", + "drm", + "xkbcommon", + "xcomposite", + "xdamage", + "xfixes", + "xrandr", + "gbm", + "pango-1.0", + "cairo", + "asound", +) + class BrowserSupportMissing(RuntimeError): """The optional Playwright driver is absent for a browser operation.""" @@ -73,6 +101,53 @@ def _opted_out() -> bool: return bool(os.environ.get(NO_AUTO_INSTALL_ENV)) +#: The Debian/Ubuntu package names matching :data:`_LINUX_CHROMIUM_SONAMES`, +#: shown as the manual alternative to ``playwright install-deps``. +_LINUX_APT_PACKAGES = ( + "libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 " + "libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 " + "libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2" +) + + +def _missing_chromium_system_libs() -> list[str]: + """Return the Chromium shared libraries missing on this Linux machine. + + Uses ``ctypes.util.find_library`` (an ``ldconfig``-based lookup: cheap, + offline, and no subprocess spawned by us). Returns an empty list on + non-Linux platforms, where Playwright ships everything Chromium needs. + """ + if sys.platform != "linux": + return [] + return [ + soname + for soname in _LINUX_CHROMIUM_SONAMES + if ctypes.util.find_library(soname) is None + ] + + +def _require_linux_system_libs() -> None: + """Refuse to download Chromium when its system libraries cannot exist. + + Fresh Linux machines without the X11/audio/NSS stack used to download the + whole browser and only then fail at launch. When libraries are missing, + print the exact remedy FIRST and abort cleanly before any download. + """ + missing = _missing_chromium_system_libs() + if not missing: + return + libs = ", ".join(missing) + raise RuntimeError( + "Chromium cannot launch on this machine yet: required system " + f"libraries are missing ({libs}).\n\n" + "Install them once with:\n\n" + " sudo python -m playwright install-deps chromium\n\n" + "or, on Debian/Ubuntu:\n\n" + f" sudo apt-get install -y {_LINUX_APT_PACKAGES}\n\n" + "Then run your command again. Nothing was downloaded." + ) + + def _chromium_present() -> bool: """Return whether Playwright's Chromium browser binary is installed. @@ -109,11 +184,18 @@ def _chromium_present() -> bool: def _install_chromium() -> None: """Run ``python -m playwright install chromium`` once, with a notice. + On Linux, verifies first that Chromium's shared libraries are present and + aborts with the exact remedy when they are not, so no download is wasted + on a browser that could not launch. + Raises: - RuntimeError: if the install subprocess fails (e.g. offline), with an - actionable message pointing at the manual command and the opt-out. + RuntimeError: if system libraries are missing (Linux), or if the + install subprocess fails (e.g. offline or behind a proxy that + blocks the Playwright CDN), with an actionable message pointing + at the manual command, the proxy variable, and the opt-out. """ require_browser_support() + _require_linux_system_libs() print(_NOTICE, file=sys.stderr, flush=True) try: subprocess.run( @@ -123,11 +205,16 @@ def _install_chromium() -> None: except (subprocess.CalledProcessError, OSError) as exc: raise RuntimeError( "openadapt-flow could not automatically download the Chromium " - "browser it needs. Run\n\n" + "browser it needs. To install it manually, run:\n\n" " playwright install chromium\n\n" - "manually (you may be offline or behind a proxy), or set " - f"{NO_AUTO_INSTALL_ENV}=1 to disable auto-install if the browser " - "is provisioned another way." + "If you are behind a corporate proxy or firewall that blocks the " + "Playwright download CDN, set HTTPS_PROXY first " + "(for example: export HTTPS_PROXY=http://proxy.example.com:8080) " + "and retry. If you are fully offline, install the browser on a " + "connected machine and copy Playwright's cache directory " + "(~/.cache/ms-playwright), or provision it another way. You can " + f"also set {NO_AUTO_INSTALL_ENV}=1 to disable auto-install " + "entirely." ) from exc @@ -139,10 +226,11 @@ def ensure_chromium_installed() -> None: (subsequent calls return immediately) and is a cheap no-op when the browser is already installed. - When the browser is missing it downloads it once via - ``playwright install chromium`` and prints a one-time notice. When - :data:`NO_AUTO_INSTALL_ENV` is set it does nothing, leaving Playwright's own - "browser not installed" error to surface at launch. + When the browser is missing it verifies Chromium's system libraries + (Linux), then downloads it once via ``playwright install chromium`` and + prints a one-time notice. When :data:`NO_AUTO_INSTALL_ENV` is set it does + nothing, leaving Playwright's own "browser not installed" error to surface + at launch. """ global _ensured require_browser_support() diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..0864cb24 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# OpenAdapt installer (browser quickstart) — https://github.com/OpenAdaptAI/openadapt-flow +# +# curl -fsSL https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/main/scripts/install.sh | sh +# +# Installs uv (a fast Python toolchain) if you don't have it, provisions +# Python 3.12 (downloading a managed interpreter only if no suitable system +# Python exists — 3.13+ is not supported yet), installs OpenAdapt with browser +# support as a persistent `openadapt` command, and finishes with a short +# environment check. +# +# Safe to re-run: it upgrades in place. Nothing runs with elevated privileges; +# read the script first if you like — that's why it's served in the clear over +# HTTPS. +set -eu + +info() { printf '\033[1;36m==>\033[0m %s\n' "$1"; } +err() { printf '\033[1;31mError:\033[0m %s\n' "$1" >&2; } + +if ! command -v curl >/dev/null 2>&1; then + err "curl is required but not installed." + exit 1 +fi + +PYTHON_VERSION="3.12" + +if ! command -v uv >/dev/null 2>&1; then + info "Installing uv (fast Python package manager)…" + curl -LsSf https://astral.sh/uv/install.sh | sh + # uv installs to ~/.local/bin by default; make it visible to this script. + export PATH="$HOME/.local/bin:$PATH" +fi + +if ! command -v uv >/dev/null 2>&1; then + err "uv was installed but isn't on your PATH yet." + err "Open a new terminal and re-run this command, or add \$HOME/.local/bin to PATH." + exit 1 +fi + +# The square brackets in 'openadapt[browser]' are glob characters in many +# shells — installing from here means nobody has to quote them by hand. +info "Installing OpenAdapt with browser support…" +uv tool install --upgrade --python "$PYTHON_VERSION" 'openadapt[browser]' + +# Make sure the installed `openadapt` command is on PATH in future shells. +uv tool update-shell >/dev/null 2>&1 || true +export PATH="$HOME/.local/bin:$PATH" + +# ---- environment check ---------------------------------------------------- +os="$(uname -s)" +arch="$(uname -m 2>/dev/null || echo unknown)" +python_status="not found" +python_bin="$(uv python find "$PYTHON_VERSION" 2>/dev/null || true)" +if [ -n "$python_bin" ]; then + python_status="$("$python_bin" --version 2>/dev/null || echo "$PYTHON_VERSION") at $python_bin" +fi +command_path="$(command -v openadapt 2>/dev/null || echo "not on PATH yet — open a new terminal first")" + +printf '\n' +info "Environment" +printf ' OS: %s (%s)\n' "$os" "$arch" +printf ' Python: %s\n' "$python_status" +printf ' Command: %s\n' "$command_path" +printf ' Browser: Chromium provisions automatically on first browser use;\n' +printf ' nothing was downloaded during this install.\n' + +info "OpenAdapt is installed. Run your first workflow:" +printf '\n openadapt quickstart\n\n' +info "If your shell can't find it yet, open a new terminal first." diff --git a/tests/test_browser_setup.py b/tests/test_browser_setup.py index 5504b8e7..0a6a7325 100644 --- a/tests/test_browser_setup.py +++ b/tests/test_browser_setup.py @@ -8,7 +8,9 @@ even across repeated calls), * the ``OPENADAPT_FLOW_NO_AUTO_INSTALL`` opt-out skips the install entirely, * a failing install surfaces an actionable error, -* importing the package triggers no install (import stays side-effect-free). +* importing the package triggers no install (import stays side-effect-free), +* on Linux, missing Chromium system libraries abort BEFORE any download with + the exact remedy (probes are monkeypatched; no network, no host state). """ from __future__ import annotations @@ -126,6 +128,7 @@ def test_missing_browser_extra_refuses_before_network_or_subprocess(monkeypatch) def test_installs_once_when_missing(monkeypatch): """Missing browser -> install runs exactly once, even on repeat calls.""" monkeypatch.setattr(bs, "_chromium_present", lambda: False) + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: []) calls = [] def fake_run(cmd, *a, **k): @@ -166,6 +169,7 @@ def _boom(): def test_failed_install_raises_actionable_error(monkeypatch): """A failing install subprocess surfaces a clear, actionable RuntimeError.""" monkeypatch.setattr(bs, "_chromium_present", lambda: False) + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: []) def fake_run(cmd, *a, **k): raise subprocess.CalledProcessError(1, cmd) @@ -178,6 +182,8 @@ def fake_run(cmd, *a, **k): msg = str(exc.value) assert "playwright install chromium" in msg assert bs.NO_AUTO_INSTALL_ENV in msg + # Proxy guidance for CDN-blocked / offline machines. + assert "HTTPS_PROXY" in msg def test_probe_failure_falls_back_to_install(monkeypatch): @@ -187,6 +193,7 @@ def _raise(): raise RuntimeError("driver blew up") monkeypatch.setattr(bs, "_chromium_present", _raise) + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: []) calls = [] monkeypatch.setattr(subprocess, "run", lambda cmd, *a, **k: calls.append(cmd)) @@ -203,3 +210,84 @@ def test_import_is_side_effect_free(monkeypatch): importlib.reload(importlib.import_module("openadapt_flow")) assert called == [] + + +# --- Linux shared-library gate --------------------------------------------- + + +def test_lib_probe_is_empty_off_linux(monkeypatch): + """Non-Linux platforms never report missing libraries.""" + monkeypatch.setattr(bs.sys, "platform", "darwin") + + def _boom(name): + raise AssertionError("find_library must not run off Linux") + + monkeypatch.setattr(bs.ctypes.util, "find_library", _boom) + + assert bs._missing_chromium_system_libs() == [] + + +def test_lib_probe_reports_only_missing_sonames(monkeypatch): + """On Linux, exactly the sonames find_library cannot resolve are listed.""" + monkeypatch.setattr(bs.sys, "platform", "linux") + present = {"nss3", "gbm"} + + def fake_find_library(name): + return "lib{}.so.9".format(name) if name in present else None + + monkeypatch.setattr(bs.ctypes.util, "find_library", fake_find_library) + + missing = bs._missing_chromium_system_libs() + + assert set(missing) == set(bs._LINUX_CHROMIUM_SONAMES) - present + # Deterministic order for stable error messages. + assert missing == [s for s in bs._LINUX_CHROMIUM_SONAMES if s not in present] + + +def test_missing_system_libs_abort_before_any_download(monkeypatch): + """Missing libraries -> remedy raised and NO download is attempted.""" + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: ["nss3", "gbm"]) + # Presence is checked before the library gate; report "missing" so the + # install path (and therefore the gate) is reached. + monkeypatch.setattr(bs, "_chromium_present", lambda: False) + calls = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append((a, k))) + + with pytest.raises(RuntimeError) as exc: + bs.ensure_chromium_installed() + + msg = str(exc.value) + assert "nss3" in msg + assert "playwright install-deps chromium" in msg # exact primary remedy + assert "apt-get install" in msg # apt alternative line + assert "Nothing was downloaded" in msg + assert calls == [] + + +def test_present_system_libs_do_not_block_install(monkeypatch): + """Empty probe result -> the normal download path proceeds unchanged.""" + monkeypatch.setattr(bs, "_chromium_present", lambda: False) + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: []) + calls = [] + monkeypatch.setattr(subprocess, "run", lambda cmd, *a, **k: calls.append(cmd)) + + bs.ensure_chromium_installed() + + assert len(calls) == 1 + assert calls[0][1:] == ["-m", "playwright", "install", "chromium"] + + +def test_opt_out_bypasses_the_library_gate(monkeypatch): + """OPENADAPT_FLOW_NO_AUTO_INSTALL skips both the lib probe and download.""" + monkeypatch.setenv(bs.NO_AUTO_INSTALL_ENV, "1") + + def _boom(): + raise AssertionError("probe must not run when opted out") + + monkeypatch.setattr(bs, "_missing_chromium_system_libs", _boom) + calls = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append((a, k))) + + bs.ensure_chromium_installed() + + assert calls == []