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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,26 @@ 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]'

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:
Expand Down
108 changes: 98 additions & 10 deletions openadapt_flow/_browser_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +28,7 @@

from __future__ import annotations

import ctypes.util
import importlib.util
import os
import re
Expand All @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand All @@ -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


Expand All @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
@@ -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."
90 changes: 89 additions & 1 deletion tests/test_browser_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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))

Expand All @@ -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 == []