From 565487f1cc567087a6f27152c4a1f57376c7eebe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 21:31:52 +0000 Subject: [PATCH 01/30] Honor PUPPETEER_CACHE_DIR + PLAYWRIGHT_BROWSERS_PATH from ambient env Puppeteer and Playwright both expose standard env vars that users (and downstream CI) expect to control browser cache locations. Previously ``browser_cache_dir`` / ``install_root`` on those providers only came from explicit abxpkg aliases, silently clobbering the user's env. - ``PuppeteerProvider.browser_cache_dir`` now hydrates from ``PUPPETEER_CACHE_DIR`` when unset; the existing ``install_root/cache`` fallback still kicks in when neither is set. - ``PlaywrightProvider.install_root`` now hydrates from ``PLAYWRIGHT_BROWSERS_PATH`` when ``ABXPKG_PLAYWRIGHT_ROOT`` / ``ABXPKG_LIB_DIR/playwright`` are also unset, so a user-exported browsers path propagates into the provider instead of being overwritten. Both providers already emit the matching env var in ``ENV``, so this just removes the asymmetry between "abxpkg exports these" and "abxpkg reads these". --- abxpkg/binprovider_playwright.py | 12 ++++++++++-- abxpkg/binprovider_puppeteer.py | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index fe3daf43..e370ef5f 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -62,9 +62,17 @@ class PlaywrightProvider(BinProvider): # ``playwright_root`` is both the abxpkg install root and the # ``PLAYWRIGHT_BROWSERS_PATH`` we export to the CLI. Leave unset to # let playwright use its own OS-default browsers path. - # Default: ABXPKG_PLAYWRIGHT_ROOT > ABXPKG_LIB_DIR/playwright > None. + # Default: ABXPKG_PLAYWRIGHT_ROOT > PLAYWRIGHT_BROWSERS_PATH (playwright's + # own env var) > ABXPKG_LIB_DIR/playwright > None. install_root: Path | None = Field( - default_factory=lambda: abxpkg_install_root_default("playwright"), + default_factory=lambda: ( + abxpkg_install_root_default("playwright") + or ( + Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() + if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") + else None + ) + ), validation_alias="playwright_root", ) # Only set in managed mode: setup()/default_abspath_handler() use it to create and read diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 3cd06ec1..6c89bb05 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -66,10 +66,18 @@ class PuppeteerProvider(BinProvider): # Only set in managed mode: setup()/default_abspath_handler() use it to expose stable # browser launch shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - # Explicit override for the directory browsers get downloaded into. When - # unset, cache_dir defaults to ``/cache``; when set, it wins - # so callers can point ``PUPPETEER_CACHE_DIR`` at an arbitrary path. - browser_cache_dir: Path | None = None + # Explicit override for the directory browsers get downloaded into. + # Hydrated from ``PUPPETEER_CACHE_DIR`` (puppeteer's own standard env var) + # when unset so callers can pin the cache dir the same way they would for + # a vanilla ``puppeteer-browsers install``. When still unset, ``cache_dir`` + # falls back to ``/cache``. + browser_cache_dir: Path | None = Field( + default_factory=lambda: ( + Path(os.environ["PUPPETEER_CACHE_DIR"]).expanduser() + if os.environ.get("PUPPETEER_CACHE_DIR") + else None + ), + ) @computed_field @property From 47a6ef55d3f6ebb9e77e50becd62e98667ad7602 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 21:46:15 +0000 Subject: [PATCH 02/30] apt/brew setup_PATH: rebuild when PATH is still empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider instance is constructed (or copied via ``get_provider_with_overrides``) and its ``_INSTALLER_BINARY`` gets resolved out-of-band — e.g. a plugin hook does a ``provider.INSTALLER_BINARY()`` availability check before handing the provider to ``Binary.install()`` — the subsequent ``install`` -> ``setup_PATH`` call hit the ``_INSTALLER_BINARY is None`` short-circuit and returned without populating ``self.PATH``. The post-install ``load()`` then searched an empty PATH, failed to find the freshly-installed binary, and raised ``Installed package did not produce runnable binary ...`` (rolling back a perfectly good install). Add ``not self.PATH`` to the rebuild condition on both AptProvider and BrewProvider so an empty PATH always forces a fresh PATH discovery regardless of whether the installer binary is already cached. --- abxpkg/binprovider_apt.py | 10 ++++++++-- abxpkg/binprovider_brew.py | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index 3bfdc146..521abdd7 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -28,8 +28,14 @@ class AptProvider(BinProvider): def setup_PATH(self, no_cache: bool = False) -> None: """Populate PATH on first use from dpkg-discovered package runtime bin dirs, not from apt-get itself.""" - if no_cache or ( - self._INSTALLER_BINARY is None + # Rebuild PATH on first use, when the caller forces no_cache, or when + # PATH is still empty — the last case covers the "INSTALLER_BINARY was + # resolved out-of-band (hook preflight etc.), so _INSTALLER_BINARY is + # non-None but self.PATH was never populated" race. + if ( + no_cache + or not self.PATH + or self._INSTALLER_BINARY is None or self._INSTALLER_BINARY.loaded_abspath is None ): dpkg_binary = EnvProvider().load("dpkg") diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 69a88dac..da8b0ec3 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -190,8 +190,13 @@ def _refresh_bin_link( def setup_PATH(self, no_cache: bool = False) -> None: """Populate PATH on first use from the resolved brew prefix and known runtime brew bin dirs.""" - if no_cache or ( - self._INSTALLER_BINARY is None + # Rebuild PATH on first use, when the caller forces no_cache, or when + # PATH is still empty — the last case covers provider copies that + # inherited a resolved ``_INSTALLER_BINARY`` but an unset ``PATH``. + if ( + no_cache + or not self.PATH + or self._INSTALLER_BINARY is None or self._INSTALLER_BINARY.loaded_abspath is None ): install_root = self.install_root From 4fe3ef2ef8dad80f631a5c953e6240e88a84b740 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:09:01 +0000 Subject: [PATCH 03/30] Playwright: separate browsers_path from install_root, honour env precedence Before this commit ``install_root`` did double duty as both the abxpkg managed root and the ``PLAYWRIGHT_BROWSERS_PATH`` export, so callers had no way to pin the browsers-path separately and explicit ``install_root`` kwargs always clobbered a user-set ``PLAYWRIGHT_BROWSERS_PATH`` env var. Split the two concepts: - ``install_root`` keeps its role as the managed provider root (bin_dir symlinks, nested npm prefix, derived.env cache). - A new ``browsers_path`` field hydrates from ``PLAYWRIGHT_BROWSERS_PATH`` and always wins when set. - ``resolved_browsers_path`` computes the final path with the same precedence puppeteer uses: 1. explicit ``browsers_path`` (field or env) wins 2. else ``install_root/cache`` when a managed root is pinned 3. else ``None`` (let playwright pick its OS default) - ``ENV`` + the sudo ``env KEY=VAL`` wrapper both emit the resolved path instead of ``install_root`` directly, so the subprocess and the host stay consistent. Matches the documented semantics for PUPPETEER_CACHE_DIR: the more specific cache-dir env var always beats the less specific install-root option; abx-plugins never has to set the env var itself because ``ABXPKG_LIB_DIR`` threads through ``install_root`` and the default ``install_root/cache`` layout covers the managed case. --- abxpkg/binprovider_playwright.py | 75 ++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index e370ef5f..e6aee52c 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -33,11 +33,14 @@ class PlaywrightProvider(BinProvider): """Playwright browser installer provider. Drives ``playwright install --with-deps `` against the - ``playwright`` npm package. When ``playwright_root`` is set it - doubles as the abxpkg install root AND ``PLAYWRIGHT_BROWSERS_PATH``: - browsers land inside it (``chromium-/`` etc.), a dedicated - npm prefix is nested under it, and each requested browser is - surfaced from ``bin_dir`` so ``load(bin_name)`` finds it directly. + ``playwright`` npm package. When ``playwright_root`` is set it acts + as the abxpkg install root: a dedicated npm prefix is nested under + it, ``bin_dir`` surfaces each requested browser so ``load(bin_name)`` + finds it directly, and ``PLAYWRIGHT_BROWSERS_PATH`` defaults to + ``/cache`` for the actual browser downloads. Callers + that want to pin the browsers path somewhere else can set the + ``browsers_path`` field (hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` + env) — that override always wins over ``install_root/cache``. When ``playwright_root`` is left unset, playwright picks its own default browsers path, the npm CLI bootstraps against the host's npm default, and ``load()`` returns the resolved ``executablePath()`` @@ -59,25 +62,30 @@ class PlaywrightProvider(BinProvider): postinstall_scripts: bool | None = Field(default=None, repr=False) min_release_age: float | None = Field(default=None, repr=False) - # ``playwright_root`` is both the abxpkg install root and the - # ``PLAYWRIGHT_BROWSERS_PATH`` we export to the CLI. Leave unset to - # let playwright use its own OS-default browsers path. - # Default: ABXPKG_PLAYWRIGHT_ROOT > PLAYWRIGHT_BROWSERS_PATH (playwright's - # own env var) > ABXPKG_LIB_DIR/playwright > None. + # ``playwright_root`` is the abxpkg-managed provider root dir. Leave + # unset to let playwright use its own OS-default browsers path. + # Default: ABXPKG_PLAYWRIGHT_ROOT > ABXPKG_LIB_DIR/playwright > None. install_root: Path | None = Field( - default_factory=lambda: ( - abxpkg_install_root_default("playwright") - or ( - Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() - if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") - else None - ) - ), + default_factory=lambda: abxpkg_install_root_default("playwright"), validation_alias="playwright_root", ) # Only set in managed mode: setup()/default_abspath_handler() use it to create and read # stable browser shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None + # Explicit override for the directory browsers get downloaded into. + # Hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` (playwright's own standard + # env var) when unset so callers can pin the cache dir the same way they + # would for a vanilla ``playwright install``. When both this and + # ``install_root`` are unset, playwright falls back to its OS default; when + # only ``install_root`` is set, ``browsers_path`` defaults to + # ``/cache`` (see ``ENV``). + browsers_path: Path | None = Field( + default_factory=lambda: ( + Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() + if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") + else None + ), + ) # Only Linux needs the sudo-first execution path for # ``playwright install --with-deps``. On macOS and elsewhere, @@ -87,9 +95,29 @@ class PlaywrightProvider(BinProvider): @computed_field @property def ENV(self) -> "dict[str, str]": - if not self.install_root: + resolved = self.resolved_browsers_path + if resolved is None: return {} - return {"PLAYWRIGHT_BROWSERS_PATH": str(self.install_root)} + return {"PLAYWRIGHT_BROWSERS_PATH": str(resolved)} + + @computed_field + @property + def resolved_browsers_path(self) -> Path | None: + """PLAYWRIGHT_BROWSERS_PATH precedence: + + 1. Explicit ``browsers_path`` (field / ``PLAYWRIGHT_BROWSERS_PATH`` env) + wins — the more specific cache-dir override always beats the less + specific install-root. + 2. Else, when ``install_root`` is pinned, default to + ``/cache`` so managed installs stay self-contained. + 3. Else leave it unset and let playwright pick its own OS-default + browsers cache directory. + """ + if self.browsers_path is not None: + return self.browsers_path + if self.install_root is not None: + return self.install_root / "cache" + return None def supports_min_release_age(self, action, no_cache: bool = False) -> bool: return False @@ -298,10 +326,11 @@ def exec( # contain our bin_dir. env = self.build_exec_env(base_env=(kwargs.pop("env", None) or os.environ)) env_assignments: list[str] = [] - if self.install_root is not None: - env["PLAYWRIGHT_BROWSERS_PATH"] = str(self.install_root) + resolved_browsers_path = self.resolved_browsers_path + if resolved_browsers_path is not None: + env["PLAYWRIGHT_BROWSERS_PATH"] = str(resolved_browsers_path) env_assignments.append( - f"PLAYWRIGHT_BROWSERS_PATH={self.install_root}", + f"PLAYWRIGHT_BROWSERS_PATH={resolved_browsers_path}", ) needs_sudo_env_wrapper = os.geteuid() != 0 and self.EUID != os.geteuid() if env_assignments and needs_sudo_env_wrapper: From 30c1e97579696169ce5f466f54116beacccd6b7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:10:18 +0000 Subject: [PATCH 04/30] README: document PUPPETEER_CACHE_DIR / PLAYWRIGHT_BROWSERS_PATH precedence Both provider sections now spell out the three-tier precedence used by the newly-exposed override fields: 1. explicit ``browser_cache_dir`` / ``browsers_path`` (or their matching env vars ``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH``) wins 2. else ``/cache`` when an install root is pinned 3. else ``None`` (let the CLI pick its OS default) Also pulled the "downloads live under install_root/cache" factoid out of PuppeteerProvider's install-root bullet and into the dedicated cache-dir bullet where it belongs. --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f45751ef..14b7ce7d 100644 --- a/README.md +++ b/README.md @@ -1074,9 +1074,11 @@ INSTALLER_BIN = "puppeteer-browsers" PATH = "" install_root = $ABXPKG_PUPPETEER_ROOT or $ABXPKG_LIB_DIR/puppeteer bin_dir = /bin +browser_cache_dir = None # hydrated from PUPPETEER_CACHE_DIR env; overrides install_root/cache ``` -- Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Downloaded browser artifacts live under `/cache` when an install root is pinned. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. +- Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. +- Cache dir / `PUPPETEER_CACHE_DIR`: browser downloads land in the directory resolved with three-tier precedence: explicit `browser_cache_dir` / `PUPPETEER_CACHE_DIR` env wins → else `/cache` when an install root is pinned → else `None`. The resolved value is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1093,12 +1095,14 @@ Source: [`abxpkg/binprovider_playwright.py`](./abxpkg/binprovider_playwright.py) ```python INSTALLER_BIN = "playwright" PATH = "" -install_root = None # when set, doubles as PLAYWRIGHT_BROWSERS_PATH +install_root = None # abxpkg-managed root dir for bin_dir / nested npm prefix bin_dir = /bin # symlink dir for resolved browsers +browsers_path = None # hydrated from PLAYWRIGHT_BROWSERS_PATH env; overrides install_root/cache euid = 0 # routes exec() through sudo-first-then-fallback ``` -- Install root: set `install_root` to pin both the abxpkg root dir AND `PLAYWRIGHT_BROWSERS_PATH` to the same directory. Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. +- Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. +- Browsers path / `PLAYWRIGHT_BROWSERS_PATH`: the actual browsers cache dir is resolved with three-tier precedence: explicit `browsers_path` / `PLAYWRIGHT_BROWSERS_PATH` env wins → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. From 08ab1ae163dc191f3ab30db7b310ea6ccdfed6da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:18:55 +0000 Subject: [PATCH 05/30] =?UTF-8?q?Playwright:=20rename=20browsers=5Fpath=20?= =?UTF-8?q?=E2=86=92=20browser=5Fcache=5Fdir=20for=20parity=20with=20Puppe?= =?UTF-8?q?teer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the Puppeteer provider's field names so both browser-installing providers expose the same API surface: - writable field: ``browser_cache_dir`` (was ``browsers_path``) - computed property: ``cache_dir`` (was ``resolved_browsers_path``) Both still default-factory from their respective env vars (``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH``) and use the same three-tier precedence documented in the README. --- README.md | 4 ++-- abxpkg/binprovider_playwright.py | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 14b7ce7d..9efef154 100644 --- a/README.md +++ b/README.md @@ -1097,12 +1097,12 @@ INSTALLER_BIN = "playwright" PATH = "" install_root = None # abxpkg-managed root dir for bin_dir / nested npm prefix bin_dir = /bin # symlink dir for resolved browsers -browsers_path = None # hydrated from PLAYWRIGHT_BROWSERS_PATH env; overrides install_root/cache +browser_cache_dir = None # hydrated from PLAYWRIGHT_BROWSERS_PATH env; overrides install_root/cache euid = 0 # routes exec() through sudo-first-then-fallback ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Browsers path / `PLAYWRIGHT_BROWSERS_PATH`: the actual browsers cache dir is resolved with three-tier precedence: explicit `browsers_path` / `PLAYWRIGHT_BROWSERS_PATH` env wins → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. +- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: the actual browsers cache dir is resolved via the `cache_dir` computed property with three-tier precedence: explicit `browser_cache_dir` / `PLAYWRIGHT_BROWSERS_PATH` env wins → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index e6aee52c..4a99e5a6 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -39,7 +39,7 @@ class PlaywrightProvider(BinProvider): finds it directly, and ``PLAYWRIGHT_BROWSERS_PATH`` defaults to ``/cache`` for the actual browser downloads. Callers that want to pin the browsers path somewhere else can set the - ``browsers_path`` field (hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` + ``browser_cache_dir`` field (hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` env) — that override always wins over ``install_root/cache``. When ``playwright_root`` is left unset, playwright picks its own default browsers path, the npm CLI bootstraps against the host's @@ -77,9 +77,9 @@ class PlaywrightProvider(BinProvider): # env var) when unset so callers can pin the cache dir the same way they # would for a vanilla ``playwright install``. When both this and # ``install_root`` are unset, playwright falls back to its OS default; when - # only ``install_root`` is set, ``browsers_path`` defaults to + # only ``install_root`` is set, ``cache_dir`` defaults to # ``/cache`` (see ``ENV``). - browsers_path: Path | None = Field( + browser_cache_dir: Path | None = Field( default_factory=lambda: ( Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") @@ -95,17 +95,17 @@ class PlaywrightProvider(BinProvider): @computed_field @property def ENV(self) -> "dict[str, str]": - resolved = self.resolved_browsers_path + resolved = self.cache_dir if resolved is None: return {} return {"PLAYWRIGHT_BROWSERS_PATH": str(resolved)} @computed_field @property - def resolved_browsers_path(self) -> Path | None: + def cache_dir(self) -> Path | None: """PLAYWRIGHT_BROWSERS_PATH precedence: - 1. Explicit ``browsers_path`` (field / ``PLAYWRIGHT_BROWSERS_PATH`` env) + 1. Explicit ``browser_cache_dir`` (field / ``PLAYWRIGHT_BROWSERS_PATH`` env) wins — the more specific cache-dir override always beats the less specific install-root. 2. Else, when ``install_root`` is pinned, default to @@ -113,8 +113,8 @@ def resolved_browsers_path(self) -> Path | None: 3. Else leave it unset and let playwright pick its own OS-default browsers cache directory. """ - if self.browsers_path is not None: - return self.browsers_path + if self.browser_cache_dir is not None: + return self.browser_cache_dir if self.install_root is not None: return self.install_root / "cache" return None @@ -326,11 +326,11 @@ def exec( # contain our bin_dir. env = self.build_exec_env(base_env=(kwargs.pop("env", None) or os.environ)) env_assignments: list[str] = [] - resolved_browsers_path = self.resolved_browsers_path - if resolved_browsers_path is not None: - env["PLAYWRIGHT_BROWSERS_PATH"] = str(resolved_browsers_path) + cache_dir = self.cache_dir + if cache_dir is not None: + env["PLAYWRIGHT_BROWSERS_PATH"] = str(cache_dir) env_assignments.append( - f"PLAYWRIGHT_BROWSERS_PATH={resolved_browsers_path}", + f"PLAYWRIGHT_BROWSERS_PATH={cache_dir}", ) needs_sudo_env_wrapper = os.geteuid() != 0 and self.EUID != os.geteuid() if env_assignments and needs_sudo_env_wrapper: From 2d52f0bbe585c096d8ad172ada24d248f9a75dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:24:47 +0000 Subject: [PATCH 06/30] =?UTF-8?q?Puppeteer/Playwright:=20collapse=20browse?= =?UTF-8?q?r=5Fcache=5Fdir=20=E2=86=92=20single=20writable=20cache=5Fdir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback (#33): providers should expose only ``install_root``, ``cache_dir``, and ``bin_dir`` — no duplicate ``browser_cache_dir`` field. Refactor to match: - Drop the separate ``browser_cache_dir`` field on both providers. - Promote ``cache_dir`` from a computed property to a writable field with a ``default_factory`` that hydrates from ``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH``. - A ``@model_validator(mode="after")`` on each provider fills ``cache_dir = install_root/cache`` when ``cache_dir`` is still ``None`` but an ``install_root`` is pinned (preserving the previous default). - Precedence at validate time: 1. explicit ``cache_dir`` kwarg wins 2. else env var (PUPPETEER_CACHE_DIR / PLAYWRIGHT_BROWSERS_PATH) 3. else ``/cache`` when an install root is pinned 4. else ``None`` (global / OS default) Uninstall + abspath checks: - ``PlaywrightProvider.default_uninstall_handler`` now iterates the resolved ``cache_dir`` (= ``PLAYWRIGHT_BROWSERS_PATH`` tree) instead of ``install_root`` when cleaning ``-*/`` dirs. - ``PlaywrightProvider.default_abspath_handler`` now scopes ``executablePath()`` hits to ``cache_dir.resolve()`` ancestry instead of ``install_root`` ancestry, so an explicit ``PLAYWRIGHT_BROWSERS_PATH`` outside ``install_root`` still satisfies ``load()``. - ``PuppeteerProvider`` already routed all cache ops through ``self.cache_dir`` — no behaviour change there, just field cleanup. README updated to reflect the collapsed API surface for both providers. --- README.md | 8 ++-- abxpkg/binprovider_playwright.py | 78 ++++++++++++++------------------ abxpkg/binprovider_puppeteer.py | 28 ++++-------- 3 files changed, 48 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 9efef154..60c3281e 100644 --- a/README.md +++ b/README.md @@ -1074,11 +1074,11 @@ INSTALLER_BIN = "puppeteer-browsers" PATH = "" install_root = $ABXPKG_PUPPETEER_ROOT or $ABXPKG_LIB_DIR/puppeteer bin_dir = /bin -browser_cache_dir = None # hydrated from PUPPETEER_CACHE_DIR env; overrides install_root/cache +cache_dir = /cache # hydrated from PUPPETEER_CACHE_DIR env when install_root is unset ``` - Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. -- Cache dir / `PUPPETEER_CACHE_DIR`: browser downloads land in the directory resolved with three-tier precedence: explicit `browser_cache_dir` / `PUPPETEER_CACHE_DIR` env wins → else `/cache` when an install root is pinned → else `None`. The resolved value is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. +- Cache dir / `PUPPETEER_CACHE_DIR`: browser downloads land in `cache_dir`, resolved with four-tier precedence at validate time: explicit `cache_dir` kwarg wins → else `PUPPETEER_CACHE_DIR` env var → else `/cache` when an install root is pinned → else `None`. The resolved value is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1097,12 +1097,12 @@ INSTALLER_BIN = "playwright" PATH = "" install_root = None # abxpkg-managed root dir for bin_dir / nested npm prefix bin_dir = /bin # symlink dir for resolved browsers -browser_cache_dir = None # hydrated from PLAYWRIGHT_BROWSERS_PATH env; overrides install_root/cache +cache_dir = /cache # hydrated from PLAYWRIGHT_BROWSERS_PATH env when install_root is unset euid = 0 # routes exec() through sudo-first-then-fallback ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: the actual browsers cache dir is resolved via the `cache_dir` computed property with three-tier precedence: explicit `browser_cache_dir` / `PLAYWRIGHT_BROWSERS_PATH` env wins → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. +- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: browser downloads land in `cache_dir`, resolved with four-tier precedence at validate time: explicit `cache_dir` kwarg wins → else `PLAYWRIGHT_BROWSERS_PATH` env var → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` (never from playwright's untouched OS-default cache when nothing is pinned). - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 4a99e5a6..7bbe64f5 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -8,8 +8,9 @@ import sys import platform from pathlib import Path +from typing import Self -from pydantic import Field, TypeAdapter, computed_field +from pydantic import Field, TypeAdapter, computed_field, model_validator from .base_types import ( BinName, @@ -72,14 +73,14 @@ class PlaywrightProvider(BinProvider): # Only set in managed mode: setup()/default_abspath_handler() use it to create and read # stable browser shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - # Explicit override for the directory browsers get downloaded into. - # Hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` (playwright's own standard - # env var) when unset so callers can pin the cache dir the same way they - # would for a vanilla ``playwright install``. When both this and - # ``install_root`` are unset, playwright falls back to its OS default; when - # only ``install_root`` is set, ``cache_dir`` defaults to - # ``/cache`` (see ``ENV``). - browser_cache_dir: Path | None = Field( + # Where browsers get downloaded. Exported as ``PLAYWRIGHT_BROWSERS_PATH`` + # to every subprocess. Precedence at validate time: + # 1. explicit ``cache_dir`` kwarg wins (more-specific override) + # 2. else ``PLAYWRIGHT_BROWSERS_PATH`` env var wins (same semantics + # for external callers invoking via env) + # 3. else ``/cache`` when an install root is pinned + # 4. else ``None`` (global mode — let playwright pick its OS default) + cache_dir: Path | None = Field( default_factory=lambda: ( Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") @@ -92,32 +93,18 @@ class PlaywrightProvider(BinProvider): # run as the normal user by default. euid: int | None = 0 if platform.system().lower() == "linux" else None + @model_validator(mode="after") + def _fill_cache_dir_from_install_root(self) -> Self: + if self.cache_dir is None and self.install_root is not None: + self.cache_dir = self.install_root / "cache" + return self + @computed_field @property def ENV(self) -> "dict[str, str]": - resolved = self.cache_dir - if resolved is None: + if not self.cache_dir: return {} - return {"PLAYWRIGHT_BROWSERS_PATH": str(resolved)} - - @computed_field - @property - def cache_dir(self) -> Path | None: - """PLAYWRIGHT_BROWSERS_PATH precedence: - - 1. Explicit ``browser_cache_dir`` (field / ``PLAYWRIGHT_BROWSERS_PATH`` env) - wins — the more specific cache-dir override always beats the less - specific install-root. - 2. Else, when ``install_root`` is pinned, default to - ``/cache`` so managed installs stay self-contained. - 3. Else leave it unset and let playwright pick its own OS-default - browsers cache directory. - """ - if self.browser_cache_dir is not None: - return self.browser_cache_dir - if self.install_root is not None: - return self.install_root / "cache" - return None + return {"PLAYWRIGHT_BROWSERS_PATH": str(self.cache_dir)} def supports_min_release_age(self, action, no_cache: bool = False) -> bool: return False @@ -605,14 +592,15 @@ def default_abspath_handler( ) if not resolved: return None - # When ``playwright_root`` is pinned, a hit from - # ``executablePath()`` that points outside that install_root tree - # (e.g. an ambient system install) should not satisfy - # ``load()`` — otherwise an unrelated host-wide playwright - # install would silently hijack resolution. - if self.install_root is not None: - root_real = self.install_root.resolve(strict=False) - if root_real not in resolved.resolve(strict=False).parents: + # When ``cache_dir`` is pinned (either explicitly, via + # ``PLAYWRIGHT_BROWSERS_PATH``, or derived from ``install_root``), + # an ``executablePath()`` hit that points outside that tree + # (e.g. an ambient system install) should not satisfy ``load()`` + # — otherwise an unrelated host-wide playwright install would + # silently hijack resolution. + if self.cache_dir is not None: + cache_real = self.cache_dir.resolve(strict=False) + if cache_real not in resolved.resolve(strict=False).parents: return None if self.bin_dir is None: return resolved @@ -763,11 +751,13 @@ def default_uninstall_handler( (self.bin_dir / bin_name).unlink(missing_ok=True) # ``playwright uninstall`` only removes *unused* browsers from - # the entire host, so drop the matching directories ourselves. - # Only touch ``playwright_root`` if the caller pinned one — we - # don't delete from playwright's own OS-default cache. - if self.install_root is not None and self.install_root.is_dir(): - for entry in self.install_root.iterdir(): + # the entire host, so drop the matching directories ourselves + # from the resolved ``cache_dir`` (= ``PLAYWRIGHT_BROWSERS_PATH``). + # Only touch it if the caller pinned one explicitly or via + # ``install_root`` — we don't delete from playwright's own + # OS-default cache. + if self.cache_dir is not None and self.cache_dir.is_dir(): + for entry in self.cache_dir.iterdir(): if entry.is_dir() and entry.name.startswith(f"{bin_name}-"): logger.info("$ %s", format_command(["rm", "-rf", str(entry)])) shutil.rmtree(entry, ignore_errors=True) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 6c89bb05..ecb8f47c 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -66,12 +66,14 @@ class PuppeteerProvider(BinProvider): # Only set in managed mode: setup()/default_abspath_handler() use it to expose stable # browser launch shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - # Explicit override for the directory browsers get downloaded into. - # Hydrated from ``PUPPETEER_CACHE_DIR`` (puppeteer's own standard env var) - # when unset so callers can pin the cache dir the same way they would for - # a vanilla ``puppeteer-browsers install``. When still unset, ``cache_dir`` - # falls back to ``/cache``. - browser_cache_dir: Path | None = Field( + # Where browsers get downloaded. Precedence at validate time: + # 1. explicit ``cache_dir`` kwarg wins (more-specific override) + # 2. else ``PUPPETEER_CACHE_DIR`` env var wins (same semantics for + # external callers invoking via env) + # 3. else ``/cache`` when an install root is pinned + # 4. else ``None`` (global mode — let puppeteer-browsers pick its + # own default cache) + cache_dir: Path | None = Field( default_factory=lambda: ( Path(os.environ["PUPPETEER_CACHE_DIR"]).expanduser() if os.environ.get("PUPPETEER_CACHE_DIR") @@ -89,22 +91,12 @@ def ENV(self) -> "dict[str, str]": def supports_postinstall_disable(self, action, no_cache: bool = False) -> bool: return action in ("install", "update") - @computed_field - @property - def cache_dir(self) -> Path | None: - # Explicit override wins so PUPPETEER_CACHE_DIR can be any path. - if self.browser_cache_dir is not None: - return self.browser_cache_dir - # Otherwise browser downloads live under ``install_root/cache`` when we - # manage an install root; global mode leaves cache ownership to the host. - if self.install_root is not None: - return self.install_root / "cache" - return None - @model_validator(mode="after") def detect_euid_to_use(self) -> Self: if self.bin_dir is None and self.install_root is not None: self.bin_dir = self.install_root / "bin" + if self.cache_dir is None and self.install_root is not None: + self.cache_dir = self.install_root / "cache" return self def setup_PATH(self, no_cache: bool = False) -> None: From 76c80cffa36d0647675b262fe147cb13fc7348bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:32:54 +0000 Subject: [PATCH 07/30] Puppeteer/Playwright: drop cache_dir field, derive purely from install_root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback: ``cache_dir`` was always 1:1 with ``install_root``, so the writable field was redundant. Collapse it to a single ``@computed_field`` on both providers: cache_dir = /cache when install_root is set cache_dir = None when install_root is unset When ``install_root`` is unset the provider exports neither ``PUPPETEER_CACHE_DIR`` nor ``PLAYWRIGHT_BROWSERS_PATH`` to subprocesses, so ``puppeteer-browsers`` / ``playwright`` fall back to whatever the ambient env + their own OS default dictates. When ``install_root`` is pinned, the computed ``cache_dir`` flows through to ``ENV``, the sudo ``env KEY=VAL`` wrapper, ``--path=...`` install args, ``default_uninstall_handler``, and ``default_abspath_handler`` scope checks — all call sites already guarded on ``is not None`` so removing the field changes no behaviour when install_root is set. Provider field surface is now just ``install_root`` + ``bin_dir`` (plus the class-level ``INSTALLER_BIN`` constant), matching the maintainer's "only install_root, cache_dir, bin_dir; no duplicates" direction. --- README.md | 8 ++--- abxpkg/binprovider_playwright.py | 52 ++++++++++++++------------------ abxpkg/binprovider_puppeteer.py | 32 ++++++++++---------- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 60c3281e..a7fb760d 100644 --- a/README.md +++ b/README.md @@ -1074,11 +1074,11 @@ INSTALLER_BIN = "puppeteer-browsers" PATH = "" install_root = $ABXPKG_PUPPETEER_ROOT or $ABXPKG_LIB_DIR/puppeteer bin_dir = /bin -cache_dir = /cache # hydrated from PUPPETEER_CACHE_DIR env when install_root is unset +cache_dir = /cache # computed; None when install_root is unset ``` - Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. -- Cache dir / `PUPPETEER_CACHE_DIR`: browser downloads land in `cache_dir`, resolved with four-tier precedence at validate time: explicit `cache_dir` kwarg wins → else `PUPPETEER_CACHE_DIR` env var → else `/cache` when an install root is pinned → else `None`. The resolved value is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. +- Cache dir / `PUPPETEER_CACHE_DIR`: `cache_dir` is a computed property — it's always `/cache` when `install_root` is pinned (and exported as `PUPPETEER_CACHE_DIR` to every subprocess), and `None` otherwise. When `install_root` is unset, abxpkg exports nothing and `puppeteer-browsers` falls back to its own default (`$PUPPETEER_CACHE_DIR` from the ambient env, otherwise `~/.cache/puppeteer`). - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1097,12 +1097,12 @@ INSTALLER_BIN = "playwright" PATH = "" install_root = None # abxpkg-managed root dir for bin_dir / nested npm prefix bin_dir = /bin # symlink dir for resolved browsers -cache_dir = /cache # hydrated from PLAYWRIGHT_BROWSERS_PATH env when install_root is unset +cache_dir = /cache # computed; None when install_root is unset euid = 0 # routes exec() through sudo-first-then-fallback ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: browser downloads land in `cache_dir`, resolved with four-tier precedence at validate time: explicit `cache_dir` kwarg wins → else `PLAYWRIGHT_BROWSERS_PATH` env var → else `/cache` when an install root is pinned → else `None` (let playwright pick its OS default). The final value is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess the provider runs. `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` (never from playwright's untouched OS-default cache when nothing is pinned). +- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: `cache_dir` is a computed property — it's always `/cache` when `install_root` is pinned (and exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess, including the `env KEY=VAL -- ...` wrapper used when we go through sudo), and `None` otherwise. When `install_root` is unset, abxpkg exports nothing and `playwright` falls back to its own default (`$PLAYWRIGHT_BROWSERS_PATH` from the ambient env, otherwise `~/.cache/ms-playwright` on Linux). `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` and never touches playwright's OS-default cache when nothing is pinned. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 7bbe64f5..53a57361 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -8,9 +8,8 @@ import sys import platform from pathlib import Path -from typing import Self -from pydantic import Field, TypeAdapter, computed_field, model_validator +from pydantic import Field, TypeAdapter, computed_field from .base_types import ( BinName, @@ -37,15 +36,14 @@ class PlaywrightProvider(BinProvider): ``playwright`` npm package. When ``playwright_root`` is set it acts as the abxpkg install root: a dedicated npm prefix is nested under it, ``bin_dir`` surfaces each requested browser so ``load(bin_name)`` - finds it directly, and ``PLAYWRIGHT_BROWSERS_PATH`` defaults to - ``/cache`` for the actual browser downloads. Callers - that want to pin the browsers path somewhere else can set the - ``browser_cache_dir`` field (hydrated from ``PLAYWRIGHT_BROWSERS_PATH`` - env) — that override always wins over ``install_root/cache``. + finds it directly, and ``PLAYWRIGHT_BROWSERS_PATH`` is pinned to + ``/cache`` for every subprocess the provider runs. When ``playwright_root`` is left unset, playwright picks its own - default browsers path, the npm CLI bootstraps against the host's - npm default, and ``load()`` returns the resolved ``executablePath()`` - directly without creating any install_root/bin_dir symlinks. + default browsers path (``$PLAYWRIGHT_BROWSERS_PATH`` from the + ambient env, otherwise ``~/.cache/ms-playwright`` on Linux), the + npm CLI bootstraps against the host's npm default, and ``load()`` + returns the resolved ``executablePath()`` directly without + creating any install_root/bin_dir symlinks. ``--with-deps`` installs system packages and requires root on Linux, so ``euid`` defaults to ``0``: the base ``BinProvider.exec`` @@ -73,31 +71,27 @@ class PlaywrightProvider(BinProvider): # Only set in managed mode: setup()/default_abspath_handler() use it to create and read # stable browser shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - # Where browsers get downloaded. Exported as ``PLAYWRIGHT_BROWSERS_PATH`` - # to every subprocess. Precedence at validate time: - # 1. explicit ``cache_dir`` kwarg wins (more-specific override) - # 2. else ``PLAYWRIGHT_BROWSERS_PATH`` env var wins (same semantics - # for external callers invoking via env) - # 3. else ``/cache`` when an install root is pinned - # 4. else ``None`` (global mode — let playwright pick its OS default) - cache_dir: Path | None = Field( - default_factory=lambda: ( - Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"]).expanduser() - if os.environ.get("PLAYWRIGHT_BROWSERS_PATH") - else None - ), - ) # Only Linux needs the sudo-first execution path for # ``playwright install --with-deps``. On macOS and elsewhere, # run as the normal user by default. euid: int | None = 0 if platform.system().lower() == "linux" else None - @model_validator(mode="after") - def _fill_cache_dir_from_install_root(self) -> Self: - if self.cache_dir is None and self.install_root is not None: - self.cache_dir = self.install_root / "cache" - return self + @computed_field + @property + def cache_dir(self) -> Path | None: + """Where browser downloads land. + + When ``install_root`` is pinned we always use + ``/cache`` and export it as ``PLAYWRIGHT_BROWSERS_PATH`` + to every subprocess. When ``install_root`` is unset we leave it + ``None`` and let ``playwright`` fall back to its own default + (``$PLAYWRIGHT_BROWSERS_PATH`` from the ambient env, otherwise + ``~/.cache/ms-playwright`` on Linux etc.). + """ + if self.install_root is not None: + return self.install_root / "cache" + return None @computed_field @property diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index ecb8f47c..8d65a01c 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -66,20 +66,22 @@ class PuppeteerProvider(BinProvider): # Only set in managed mode: setup()/default_abspath_handler() use it to expose stable # browser launch shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - # Where browsers get downloaded. Precedence at validate time: - # 1. explicit ``cache_dir`` kwarg wins (more-specific override) - # 2. else ``PUPPETEER_CACHE_DIR`` env var wins (same semantics for - # external callers invoking via env) - # 3. else ``/cache`` when an install root is pinned - # 4. else ``None`` (global mode — let puppeteer-browsers pick its - # own default cache) - cache_dir: Path | None = Field( - default_factory=lambda: ( - Path(os.environ["PUPPETEER_CACHE_DIR"]).expanduser() - if os.environ.get("PUPPETEER_CACHE_DIR") - else None - ), - ) + + @computed_field + @property + def cache_dir(self) -> Path | None: + """Where browser downloads land. + + When ``install_root`` is pinned we always use + ``/cache`` and export it as ``PUPPETEER_CACHE_DIR`` + to every subprocess. When ``install_root`` is unset we leave it + ``None`` and let ``puppeteer-browsers`` fall back to its own + default (``$PUPPETEER_CACHE_DIR`` from the ambient env, otherwise + ``~/.cache/puppeteer``). + """ + if self.install_root is not None: + return self.install_root / "cache" + return None @computed_field @property @@ -95,8 +97,6 @@ def supports_postinstall_disable(self, action, no_cache: bool = False) -> bool: def detect_euid_to_use(self) -> Self: if self.bin_dir is None and self.install_root is not None: self.bin_dir = self.install_root / "bin" - if self.cache_dir is None and self.install_root is not None: - self.cache_dir = self.install_root / "cache" return self def setup_PATH(self, no_cache: bool = False) -> None: From 061d4363fddbc26daedc22542f45297a8587fc7a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:40:47 +0000 Subject: [PATCH 08/30] Puppeteer/Playwright cache_dir: honour env var fallback when install_root=None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ``install_root=None`` the computed ``cache_dir`` used to return ``None`` unconditionally, so ``uninstall()`` and the ``abspath`` scope-check couldn't target a user-configured ``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH`` — ``load()`` still worked because the subprocess inherited the ambient env, but ``uninstall()`` silently skipped cleanup of the user's custom directory. Fix the computed property for both providers so ``cache_dir`` resolves the ambient env var whenever ``install_root`` is unset: install_root set -> /cache install_root=None + env var set -> Path($PUPPETEER_CACHE_DIR) / Path($PLAYWRIGHT_BROWSERS_PATH) install_root=None + env unset -> None (CLI's OS default) Every downstream call site (install ``--path=`` arg, ENV export, sudo ``env KEY=VAL --`` wrapper, ``default_uninstall_handler``, ``default_abspath_handler`` scope checks, cache mkdir/chown) already routes through ``self.cache_dir`` with ``is not None`` guards, so ``load()``/``install()``/``uninstall()`` now all target the same directory the user configured externally without any other changes. --- README.md | 4 ++-- abxpkg/binprovider_playwright.py | 15 ++++++++++----- abxpkg/binprovider_puppeteer.py | 15 ++++++++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a7fb760d..243b063d 100644 --- a/README.md +++ b/README.md @@ -1078,7 +1078,7 @@ cache_dir = /cache # computed; None when install_root is unset ``` - Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. -- Cache dir / `PUPPETEER_CACHE_DIR`: `cache_dir` is a computed property — it's always `/cache` when `install_root` is pinned (and exported as `PUPPETEER_CACHE_DIR` to every subprocess), and `None` otherwise. When `install_root` is unset, abxpkg exports nothing and `puppeteer-browsers` falls back to its own default (`$PUPPETEER_CACHE_DIR` from the ambient env, otherwise `~/.cache/puppeteer`). +- Cache dir / `PUPPETEER_CACHE_DIR`: `cache_dir` is a computed property. When `install_root` is pinned it's always `/cache`. When `install_root` is unset it reads the ambient `$PUPPETEER_CACHE_DIR` env var so ``load()`` / ``uninstall()`` / scope checks target the same directory the user configured externally (``None`` when the env var is also unset — puppeteer-browsers then falls back to `~/.cache/puppeteer`). The resolved `cache_dir` is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1102,7 +1102,7 @@ euid = 0 # routes exec() through sudo-first-then-fallbac ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: `cache_dir` is a computed property — it's always `/cache` when `install_root` is pinned (and exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess, including the `env KEY=VAL -- ...` wrapper used when we go through sudo), and `None` otherwise. When `install_root` is unset, abxpkg exports nothing and `playwright` falls back to its own default (`$PLAYWRIGHT_BROWSERS_PATH` from the ambient env, otherwise `~/.cache/ms-playwright` on Linux). `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` and never touches playwright's OS-default cache when nothing is pinned. +- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: `cache_dir` is a computed property. When `install_root` is pinned it's always `/cache`. When `install_root` is unset it reads the ambient `$PLAYWRIGHT_BROWSERS_PATH` env var so ``load()`` / ``uninstall()`` / scope checks target the same directory the user configured externally (``None`` when the env var is also unset — playwright then falls back to `~/.cache/ms-playwright` on Linux). The resolved `cache_dir` is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess (including the `env KEY=VAL -- ...` wrapper used when we go through sudo). `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` — and when neither `install_root` nor `$PLAYWRIGHT_BROWSERS_PATH` is set, it touches nothing, leaving playwright's OS-default cache alone. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 53a57361..0955251e 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -83,14 +83,19 @@ def cache_dir(self) -> Path | None: """Where browser downloads land. When ``install_root`` is pinned we always use - ``/cache`` and export it as ``PLAYWRIGHT_BROWSERS_PATH`` - to every subprocess. When ``install_root`` is unset we leave it - ``None`` and let ``playwright`` fall back to its own default - (``$PLAYWRIGHT_BROWSERS_PATH`` from the ambient env, otherwise - ``~/.cache/ms-playwright`` on Linux etc.). + ``/cache``. When ``install_root`` is unset we fall + back to the caller's ``PLAYWRIGHT_BROWSERS_PATH`` env var + (playwright's native convention) so ``load``/``uninstall``/ + scope checks all target the same directory the user already + configured externally. When neither is set we return ``None`` + and let playwright pick its own default + (``~/.cache/ms-playwright`` on Linux etc.). """ if self.install_root is not None: return self.install_root / "cache" + env_override = os.environ.get("PLAYWRIGHT_BROWSERS_PATH") + if env_override: + return Path(env_override).expanduser() return None @computed_field diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 8d65a01c..d3222746 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -73,14 +73,19 @@ def cache_dir(self) -> Path | None: """Where browser downloads land. When ``install_root`` is pinned we always use - ``/cache`` and export it as ``PUPPETEER_CACHE_DIR`` - to every subprocess. When ``install_root`` is unset we leave it - ``None`` and let ``puppeteer-browsers`` fall back to its own - default (``$PUPPETEER_CACHE_DIR`` from the ambient env, otherwise - ``~/.cache/puppeteer``). + ``/cache``. When ``install_root`` is unset we fall + back to the caller's ``PUPPETEER_CACHE_DIR`` env var (the + puppeteer-browsers native convention) so ``load``/``uninstall``/ + scope checks all target the same directory the user already + configured externally. When neither is set we return ``None`` + and let puppeteer-browsers pick its own default + (``~/.cache/puppeteer``). """ if self.install_root is not None: return self.install_root / "cache" + env_override = os.environ.get("PUPPETEER_CACHE_DIR") + if env_override: + return Path(env_override).expanduser() return None @computed_field From 20b1d6f7fd12cc65897e31f76637d7adf3a11836 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:44:37 +0000 Subject: [PATCH 09/30] Puppeteer/Playwright cache_dir: drop env-var fallback, go passthrough when install_root=None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``cache_dir`` is now a tiny computed helper that returns ``/cache`` when ``install_root`` is pinned and ``None`` otherwise — no env-var fallback. This flips the ``install_root=None`` mode to pure passthrough: - The caller's ambient ``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH`` passes through to every subprocess unchanged (abxpkg exports nothing of its own). - ``load()`` trusts whatever ``puppeteer-browsers list`` / playwright-core ``executablePath()`` reports — no scoping applied. - ``uninstall()`` is a no-op when ``install_root=None``: we refuse to rmtree the user's cache because we don't own it. Users who want managed uninstall should set ``install_root``; users who stay in ambient mode manage their own cache via the CLI or their own tooling. - ``install()`` only passes ``--path=`` / sets ``PLAYWRIGHT_BROWSERS_PATH`` when we own the root; otherwise the CLIs install into their own default. Every existing call site already guarded on ``self.cache_dir is not None``, so dropping the env-var branch changes no managed-mode behaviour — it just stops abxpkg from claiming ownership of a cache dir the user controls. --- README.md | 4 ++-- abxpkg/binprovider_playwright.py | 28 ++++++++++++---------------- abxpkg/binprovider_puppeteer.py | 28 +++++++++++----------------- 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 243b063d..df31d5d8 100644 --- a/README.md +++ b/README.md @@ -1078,7 +1078,7 @@ cache_dir = /cache # computed; None when install_root is unset ``` - Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. -- Cache dir / `PUPPETEER_CACHE_DIR`: `cache_dir` is a computed property. When `install_root` is pinned it's always `/cache`. When `install_root` is unset it reads the ambient `$PUPPETEER_CACHE_DIR` env var so ``load()`` / ``uninstall()`` / scope checks target the same directory the user configured externally (``None`` when the env var is also unset — puppeteer-browsers then falls back to `~/.cache/puppeteer`). The resolved `cache_dir` is exported as `PUPPETEER_CACHE_DIR` to every subprocess the provider runs. +- Cache dir: `cache_dir` is a computed helper property. When `install_root` is pinned it's `/cache` and abxpkg owns it — exported as `PUPPETEER_CACHE_DIR` to every subprocess, used for `--path=` on `puppeteer-browsers install` / `list`, and rmtree'd per-browser on `uninstall()`. When `install_root` is unset, `cache_dir` is `None`: the provider is in pure passthrough mode. The caller's ambient `$PUPPETEER_CACHE_DIR` (or the CLI's `~/.cache/puppeteer` default) flows through to subprocesses unchanged, `load()` trusts whatever path `puppeteer-browsers list` reports, and `uninstall()` is a no-op — we don't touch the user's own cache. - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1102,7 +1102,7 @@ euid = 0 # routes exec() through sudo-first-then-fallbac ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Cache dir / `PLAYWRIGHT_BROWSERS_PATH`: `cache_dir` is a computed property. When `install_root` is pinned it's always `/cache`. When `install_root` is unset it reads the ambient `$PLAYWRIGHT_BROWSERS_PATH` env var so ``load()`` / ``uninstall()`` / scope checks target the same directory the user configured externally (``None`` when the env var is also unset — playwright then falls back to `~/.cache/ms-playwright` on Linux). The resolved `cache_dir` is exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess (including the `env KEY=VAL -- ...` wrapper used when we go through sudo). `uninstall()` deletes matching `-*/` dirs from the resolved `cache_dir` — and when neither `install_root` nor `$PLAYWRIGHT_BROWSERS_PATH` is set, it touches nothing, leaving playwright's OS-default cache alone. +- Cache dir: `cache_dir` is a computed helper property. When `install_root` is pinned it's `/cache` and abxpkg owns it — exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess (including the `env KEY=VAL -- ...` wrapper used when we go through sudo), used to scope `executablePath()` hits on `load()`, and walked for per-browser rmtree on `uninstall()`. When `install_root` is unset, `cache_dir` is `None`: the provider is in pure passthrough mode. The caller's ambient `$PLAYWRIGHT_BROWSERS_PATH` (or playwright's `~/.cache/ms-playwright` default on Linux) flows through to subprocesses unchanged, `load()` trusts whatever path `executablePath()` reports, and `uninstall()` is a no-op — we don't touch the user's own cache. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 0955251e..de8c1f81 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -80,23 +80,19 @@ class PlaywrightProvider(BinProvider): @computed_field @property def cache_dir(self) -> Path | None: - """Where browser downloads land. - - When ``install_root`` is pinned we always use - ``/cache``. When ``install_root`` is unset we fall - back to the caller's ``PLAYWRIGHT_BROWSERS_PATH`` env var - (playwright's native convention) so ``load``/``uninstall``/ - scope checks all target the same directory the user already - configured externally. When neither is set we return ``None`` - and let playwright pick its own default - (``~/.cache/ms-playwright`` on Linux etc.). + """``/cache`` when managed, else ``None``. + + Internal helper for the install/uninstall/load sites. When + ``install_root`` is unset we stay out of the way: the ambient + ``PLAYWRIGHT_BROWSERS_PATH`` (or playwright's + ``~/.cache/ms-playwright`` default) passes through to + subprocesses untouched, ``default_abspath_handler`` trusts + whatever path ``executablePath()`` reports, and + ``default_uninstall_handler`` skips rmtree of the user's cache. """ - if self.install_root is not None: - return self.install_root / "cache" - env_override = os.environ.get("PLAYWRIGHT_BROWSERS_PATH") - if env_override: - return Path(env_override).expanduser() - return None + if self.install_root is None: + return None + return self.install_root / "cache" @computed_field @property diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index d3222746..739c5189 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -70,28 +70,22 @@ class PuppeteerProvider(BinProvider): @computed_field @property def cache_dir(self) -> Path | None: - """Where browser downloads land. - - When ``install_root`` is pinned we always use - ``/cache``. When ``install_root`` is unset we fall - back to the caller's ``PUPPETEER_CACHE_DIR`` env var (the - puppeteer-browsers native convention) so ``load``/``uninstall``/ - scope checks all target the same directory the user already - configured externally. When neither is set we return ``None`` - and let puppeteer-browsers pick its own default - (``~/.cache/puppeteer``). + """``/cache`` when managed, else ``None``. + + Internal helper for the install/uninstall/load sites. When + ``install_root`` is unset we stay out of the way: the ambient + ``PUPPETEER_CACHE_DIR`` (or the CLI's ``~/.cache/puppeteer`` + default) passes through to subprocesses untouched, and we + refuse to rmtree anything in the user's cache on ``uninstall``. """ - if self.install_root is not None: - return self.install_root / "cache" - env_override = os.environ.get("PUPPETEER_CACHE_DIR") - if env_override: - return Path(env_override).expanduser() - return None + if self.install_root is None: + return None + return self.install_root / "cache" @computed_field @property def ENV(self) -> "dict[str, str]": - if not self.cache_dir: + if self.cache_dir is None: return {} return {"PUPPETEER_CACHE_DIR": str(self.cache_dir)} From 10c37591e34eacd0341720d12710a93b11cfd7c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:51:48 +0000 Subject: [PATCH 10/30] Puppeteer/Playwright uninstall: resolve path via load() then rmtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``puppeteer-browsers clear`` wipes the whole cache (no per-browser filter) and ``playwright uninstall`` rejects browser-name arguments, so neither CLI can do the per-browser uninstall abxpkg exposes. Switch both uninstall handlers to: 1. ``self.load(bin_name, no_cache=True)`` — playwright-core's ``executablePath()`` / puppeteer-browsers' ``list`` already read the right path from the subprocess env, so load() handles all three cases uniformly: - managed ``install_root`` → abxpkg exports the env var itself - unmanaged default → CLI's own OS default - custom ``PUPPETEER_CACHE_DIR`` / ``PLAYWRIGHT_BROWSERS_PATH`` → ambient env passes through via ``build_exec_env`` 2. Walk up from the resolved abspath to find the browser's top-level dir (``//`` for puppeteer, ``/-/`` for playwright) and rmtree it. No more ``self.cache_dir`` lookups in either uninstall handler, and the existing ``bin_dir`` symlink cleanup still runs first so ``load`` stops resolving a stale shim if rmtree fails partway through. --- abxpkg/binprovider_playwright.py | 37 ++++++++++++++++++++------------ abxpkg/binprovider_puppeteer.py | 29 +++++++++++++++++++------ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index de8c1f81..66cb35bb 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -739,23 +739,32 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - # Drop the symlink first (if we're managing one) so ``load()`` - # stops seeing the tool even if browser dir removal partially - # fails. + # Drop the managed shim first so ``load()`` stops returning the + # symlink even if the browser-dir rmtree partially fails. if self.bin_dir is not None: (self.bin_dir / bin_name).unlink(missing_ok=True) - # ``playwright uninstall`` only removes *unused* browsers from - # the entire host, so drop the matching directories ourselves - # from the resolved ``cache_dir`` (= ``PLAYWRIGHT_BROWSERS_PATH``). - # Only touch it if the caller pinned one explicitly or via - # ``install_root`` — we don't delete from playwright's own - # OS-default cache. - if self.cache_dir is not None and self.cache_dir.is_dir(): - for entry in self.cache_dir.iterdir(): - if entry.is_dir() and entry.name.startswith(f"{bin_name}-"): - logger.info("$ %s", format_command(["rm", "-rf", str(entry)])) - shutil.rmtree(entry, ignore_errors=True) + # Use ``load()`` to resolve the actual installed browser + # executable — ``playwright-core``'s ``executablePath()`` reads + # ``PLAYWRIGHT_BROWSERS_PATH`` from the subprocess env, which + # the provider exports when ``install_root`` is set and which + # otherwise passes through from the ambient env. This single + # call covers managed, OS-default, and user-env-var modes. + # Then walk up from that abspath to find the + # ``-/`` dir and rmtree it — playwright's + # own ``uninstall`` CLI has no per-browser argument, so this + # is still the only way to remove a specific browser. + try: + loaded = self.load(bin_name, quiet=True, no_cache=True) + except Exception: + loaded = None + loaded_abspath = loaded.loaded_abspath if loaded else None + if loaded_abspath is not None: + for parent in Path(loaded_abspath).resolve().parents: + if parent.name.startswith(f"{bin_name}-"): + logger.info("$ %s", format_command(["rm", "-rf", str(parent)])) + shutil.rmtree(parent, ignore_errors=True) + break return True diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 739c5189..3d5bb78d 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -699,16 +699,31 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - install_args = list(install_args or self.get_install_args(bin_name)) - browser_name = self._browser_name(bin_name, install_args) + # Drop the managed shim first so ``load()`` stops returning the + # symlink even if the browser-dir rmtree partially fails. if self.bin_dir is not None: bin_path = self.bin_dir / bin_name if bin_path.exists() or bin_path.is_symlink(): logger.info("$ %s", format_command(["rm", "-f", str(bin_path)])) bin_path.unlink(missing_ok=True) - if self.cache_dir is not None: - browser_dir = self.cache_dir / browser_name - if browser_dir.exists(): - logger.info("$ %s", format_command(["rm", "-rf", str(browser_dir)])) - shutil.rmtree(browser_dir, ignore_errors=True) + + # Use ``load()`` to resolve the actual installed browser + # executable — load honours managed install_root, the ambient + # PUPPETEER_CACHE_DIR env var, and puppeteer-browsers' own + # default, so this single call handles all three cases. Then + # walk up from that abspath to find the browser's top-level + # directory (``//``) and rmtree it. + install_args = list(install_args or self.get_install_args(bin_name)) + browser_name = self._browser_name(bin_name, install_args) + try: + loaded = self.load(bin_name, quiet=True, no_cache=True) + except Exception: + loaded = None + loaded_abspath = loaded.loaded_abspath if loaded else None + if loaded_abspath is not None: + for parent in Path(loaded_abspath).resolve().parents: + if parent.name == browser_name: + logger.info("$ %s", format_command(["rm", "-rf", str(parent)])) + shutil.rmtree(parent, ignore_errors=True) + break return True From 836123bbfce123b817865adba95140e895676f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:00:40 +0000 Subject: [PATCH 11/30] Puppeteer/Playwright: drop cache_dir entirely, inline install_root/cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``cache_dir`` was a one-line computed helper (``install_root/cache`` when managed, ``None`` otherwise) that only existed as a convenience alias — every callsite already branched on ``is not None``. Remove the computed field and inline ``install_root / "cache"`` at each use: - ``ENV`` now returns ``{}`` when ``install_root`` is None and ``{"PUPPETEER_CACHE_DIR": "/cache"}`` / ``{"PLAYWRIGHT_BROWSERS_PATH": "/cache"}`` otherwise. - ``setup`` creates ``/cache`` next to the existing ``install_root.mkdir``. - ``_normalize_install_args`` / ``_list_installed_browsers`` pass ``--path=/cache`` only when managed. - ``_cleanup_partial_browser_cache`` takes an early-return when ``install_root`` is None and uses a local ``cache_dir`` var for the glob/rmtree loop. - Playwright ``build_exec_env`` sudo wrapper and ``default_abspath_handler`` scope check both use a local ``cache_dir`` var under ``install_root is not None``. - Uninstall handlers already resolved the browser dir via ``load()`` in the previous commit, so they don't touch cache_dir at all. Net result: both providers' public field surface is just ``install_root`` + ``bin_dir``, matching the "only install_root/bin_dir" direction. --- README.md | 6 +-- abxpkg/binprovider_playwright.py | 44 ++++++----------- abxpkg/binprovider_puppeteer.py | 83 ++++++++++++++------------------ 3 files changed, 53 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index df31d5d8..db976579 100644 --- a/README.md +++ b/README.md @@ -1074,11 +1074,10 @@ INSTALLER_BIN = "puppeteer-browsers" PATH = "" install_root = $ABXPKG_PUPPETEER_ROOT or $ABXPKG_LIB_DIR/puppeteer bin_dir = /bin -cache_dir = /cache # computed; None when install_root is unset ``` - Install root: set `install_root` for the root dir and `bin_dir` for symlinked executables. Leave it unset for ambient/global mode, where cache ownership stays with the host and `INSTALLER_BINARY` must already be resolvable from the ambient provider set. -- Cache dir: `cache_dir` is a computed helper property. When `install_root` is pinned it's `/cache` and abxpkg owns it — exported as `PUPPETEER_CACHE_DIR` to every subprocess, used for `--path=` on `puppeteer-browsers install` / `list`, and rmtree'd per-browser on `uninstall()`. When `install_root` is unset, `cache_dir` is `None`: the provider is in pure passthrough mode. The caller's ambient `$PUPPETEER_CACHE_DIR` (or the CLI's `~/.cache/puppeteer` default) flows through to subprocesses unchanged, `load()` trusts whatever path `puppeteer-browsers list` reports, and `uninstall()` is a no-op — we don't touch the user's own cache. +- Browser cache: when `install_root` is pinned, abxpkg manages `/cache` end-to-end — it's exported as `PUPPETEER_CACHE_DIR` to every subprocess, used for `--path=` on `puppeteer-browsers install` / `list`, and `uninstall()` resolves the real browser directory via `load()` then rmtrees it. When `install_root` is unset the provider is in pure passthrough mode: the caller's ambient `$PUPPETEER_CACHE_DIR` (or the CLI's `~/.cache/puppeteer` default) flows through to subprocesses unchanged, `load()` trusts whatever path `puppeteer-browsers list` reports, and `uninstall()` still rmtrees the real browser directory returned by `load()` — leaving any unrelated browsers in the shared cache alone. - Auto-switching: bootstraps `@puppeteer/browsers` through `NpmProvider` and then uses that CLI for browser installs. - `dry_run`: shared behavior. - Security: `min_release_age` is unsupported for browser installs and is ignored with a warning if explicitly requested. `postinstall_scripts=False` is supported for the underlying npm bootstrap path, and `ABXPKG_POSTINSTALL_SCRIPTS` hydrates the provider default here. @@ -1097,12 +1096,11 @@ INSTALLER_BIN = "playwright" PATH = "" install_root = None # abxpkg-managed root dir for bin_dir / nested npm prefix bin_dir = /bin # symlink dir for resolved browsers -cache_dir = /cache # computed; None when install_root is unset euid = 0 # routes exec() through sudo-first-then-fallback ``` - Install root: set `install_root` to pin the abxpkg-managed root dir (where `bin_dir` symlinks and the nested npm prefix live). Leave it unset to let playwright use its own OS-default browsers path (`~/.cache/ms-playwright` on Linux etc.) — in that case abxpkg maintains no symlink dir or npm prefix at all, the `playwright` npm CLI bootstraps against the host's npm default, and `load()` returns the resolved `executablePath()` directly. `bin_dir` overrides the symlink directory when `install_root` is pinned. -- Cache dir: `cache_dir` is a computed helper property. When `install_root` is pinned it's `/cache` and abxpkg owns it — exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess (including the `env KEY=VAL -- ...` wrapper used when we go through sudo), used to scope `executablePath()` hits on `load()`, and walked for per-browser rmtree on `uninstall()`. When `install_root` is unset, `cache_dir` is `None`: the provider is in pure passthrough mode. The caller's ambient `$PLAYWRIGHT_BROWSERS_PATH` (or playwright's `~/.cache/ms-playwright` default on Linux) flows through to subprocesses unchanged, `load()` trusts whatever path `executablePath()` reports, and `uninstall()` is a no-op — we don't touch the user's own cache. +- Browser cache: when `install_root` is pinned, abxpkg manages `/cache` end-to-end — exported as `PLAYWRIGHT_BROWSERS_PATH` to every subprocess (including the `env KEY=VAL -- ...` wrapper used when we go through sudo), used to scope `executablePath()` hits on `load()`, and `uninstall()` resolves the real browser directory via `load()` then rmtrees it. When `install_root` is unset the provider is in pure passthrough mode: the caller's ambient `$PLAYWRIGHT_BROWSERS_PATH` (or playwright's `~/.cache/ms-playwright` default on Linux) flows through to subprocesses unchanged, `load()` trusts whatever path `executablePath()` reports, and `uninstall()` still rmtrees the real browser directory returned by `load()`. - Auto-switching: bootstraps the `playwright` npm package through `NpmProvider`, then runs `playwright install --with-deps ` against it. Resolves each installed browser's real executable via the `playwright-core` Node.js API (`chromium.executablePath()` etc.) and writes a symlink into `bin_dir` when one is configured. - `dry_run`: shared behavior — the install handler short-circuits to a placeholder without touching the host. - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 66cb35bb..23de7302 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -77,29 +77,16 @@ class PlaywrightProvider(BinProvider): # run as the normal user by default. euid: int | None = 0 if platform.system().lower() == "linux" else None - @computed_field - @property - def cache_dir(self) -> Path | None: - """``/cache`` when managed, else ``None``. - - Internal helper for the install/uninstall/load sites. When - ``install_root`` is unset we stay out of the way: the ambient - ``PLAYWRIGHT_BROWSERS_PATH`` (or playwright's - ``~/.cache/ms-playwright`` default) passes through to - subprocesses untouched, ``default_abspath_handler`` trusts - whatever path ``executablePath()`` reports, and - ``default_uninstall_handler`` skips rmtree of the user's cache. - """ - if self.install_root is None: - return None - return self.install_root / "cache" - @computed_field @property def ENV(self) -> "dict[str, str]": - if not self.cache_dir: + # In managed mode we pin ``PLAYWRIGHT_BROWSERS_PATH`` to + # ``/cache``. In unmanaged mode we export nothing + # and the ambient env (or playwright's own + # ``~/.cache/ms-playwright`` default) passes through untouched. + if self.install_root is None: return {} - return {"PLAYWRIGHT_BROWSERS_PATH": str(self.cache_dir)} + return {"PLAYWRIGHT_BROWSERS_PATH": str(self.install_root / "cache")} def supports_min_release_age(self, action, no_cache: bool = False) -> bool: return False @@ -308,8 +295,8 @@ def exec( # contain our bin_dir. env = self.build_exec_env(base_env=(kwargs.pop("env", None) or os.environ)) env_assignments: list[str] = [] - cache_dir = self.cache_dir - if cache_dir is not None: + if self.install_root is not None: + cache_dir = self.install_root / "cache" env["PLAYWRIGHT_BROWSERS_PATH"] = str(cache_dir) env_assignments.append( f"PLAYWRIGHT_BROWSERS_PATH={cache_dir}", @@ -587,14 +574,13 @@ def default_abspath_handler( ) if not resolved: return None - # When ``cache_dir`` is pinned (either explicitly, via - # ``PLAYWRIGHT_BROWSERS_PATH``, or derived from ``install_root``), - # an ``executablePath()`` hit that points outside that tree - # (e.g. an ambient system install) should not satisfy ``load()`` - # — otherwise an unrelated host-wide playwright install would - # silently hijack resolution. - if self.cache_dir is not None: - cache_real = self.cache_dir.resolve(strict=False) + # When ``install_root`` is pinned, an ``executablePath()`` hit + # that points outside our managed cache tree (e.g. an ambient + # system install) should not satisfy ``load()`` — otherwise an + # unrelated host-wide playwright install would silently hijack + # resolution. + if self.install_root is not None: + cache_real = (self.install_root / "cache").resolve(strict=False) if cache_real not in resolved.resolve(strict=False).parents: return None if self.bin_dir is None: diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 3d5bb78d..59a3d6a9 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -67,27 +67,16 @@ class PuppeteerProvider(BinProvider): # browser launch shims under ``/bin``; global mode leaves it unset. bin_dir: Path | None = None - @computed_field - @property - def cache_dir(self) -> Path | None: - """``/cache`` when managed, else ``None``. - - Internal helper for the install/uninstall/load sites. When - ``install_root`` is unset we stay out of the way: the ambient - ``PUPPETEER_CACHE_DIR`` (or the CLI's ``~/.cache/puppeteer`` - default) passes through to subprocesses untouched, and we - refuse to rmtree anything in the user's cache on ``uninstall``. - """ - if self.install_root is None: - return None - return self.install_root / "cache" - @computed_field @property def ENV(self) -> "dict[str, str]": - if self.cache_dir is None: + # In managed mode we pin ``PUPPETEER_CACHE_DIR`` to + # ``/cache``. In unmanaged mode we export nothing + # and the ambient env (or puppeteer-browsers' own + # ``~/.cache/puppeteer`` default) passes through untouched. + if self.install_root is None: return {} - return {"PUPPETEER_CACHE_DIR": str(self.cache_dir)} + return {"PUPPETEER_CACHE_DIR": str(self.install_root / "cache")} def supports_postinstall_disable(self, action, no_cache: bool = False) -> bool: return action in ("install", "update") @@ -210,7 +199,9 @@ def setup( owner_paths=( self.install_root, self.bin_dir, - self.cache_dir, + self.install_root / "cache" + if self.install_root is not None + else None, self.install_root / "npm" if self.install_root is not None else None, @@ -246,10 +237,9 @@ def setup( if self.install_root is not None: self.install_root.mkdir(parents=True, exist_ok=True) + (self.install_root / "cache").mkdir(parents=True, exist_ok=True) if self.bin_dir is not None: self.bin_dir.mkdir(parents=True, exist_ok=True) - if self.cache_dir is not None: - self.cache_dir.mkdir(parents=True, exist_ok=True) cli_binary = self._cli_binary( postinstall_scripts=postinstall_scripts, @@ -300,8 +290,8 @@ def _normalize_install_args(self, install_args: Iterable[str]) -> list[str]: if arg_str.startswith("--path="): continue normalized.append(arg_str) - if self.cache_dir is not None: - normalized.append(f"--path={self.cache_dir}") + if self.install_root is not None: + normalized.append(f"--path={self.install_root / 'cache'}") return normalized def _list_installed_browsers( @@ -315,8 +305,8 @@ def _list_installed_browsers( if not installer_bin: return [] cmd = ["list"] - if self.cache_dir is not None: - cmd.append(f"--path={self.cache_dir}") + if self.install_root is not None: + cmd.append(f"--path={self.install_root / 'cache'}") proc = self.exec( bin_name=installer_bin, cmd=cmd, @@ -448,10 +438,11 @@ def _cleanup_partial_browser_cache( install_output: str, browser_name: str, ) -> bool: - if self.cache_dir is None: + if self.install_root is None: return False + cache_dir = self.install_root / "cache" targets: set[Path] = set() - browser_cache_dir = self.cache_dir / browser_name + browser_cache_dir = cache_dir / browser_name missing_dir_match = re.search( r"browser folder \(([^)]+)\) exists but the executable", @@ -473,7 +464,7 @@ def _cleanup_partial_browser_cache( targets.update(browser_cache_dir.glob(f"*{build_id}*")) removed_any = False - resolved_cache = self.cache_dir.resolve(strict=False) + resolved_cache = cache_dir.resolve(strict=False) for target in targets: resolved_target = target.resolve(strict=False) if not ( @@ -551,27 +542,25 @@ def _run_install_with_sudo( cwd=self.install_root or ".", timeout=self.install_timeout, ) - if ( - proc.returncode == 0 - and self.cache_dir is not None - and self.cache_dir.exists() - ): - uid = os.getuid() - gid = os.getgid() - chown_proc = self.exec( - bin_name=sudo_binary.loaded_abspath, - cmd=["chown", "-R", f"{uid}:{gid}", str(self.cache_dir)], - cwd=self.install_root or ".", - timeout=30, - quiet=True, - ) - if chown_proc.returncode != 0: - log_subprocess_output( - logger, - f"{self.__class__.__name__} sudo chown", - chown_proc.stdout, - chown_proc.stderr, + if proc.returncode == 0 and self.install_root is not None: + cache_dir = self.install_root / "cache" + if cache_dir.exists(): + uid = os.getuid() + gid = os.getgid() + chown_proc = self.exec( + bin_name=sudo_binary.loaded_abspath, + cmd=["chown", "-R", f"{uid}:{gid}", str(cache_dir)], + cwd=self.install_root or ".", + timeout=30, + quiet=True, ) + if chown_proc.returncode != 0: + log_subprocess_output( + logger, + f"{self.__class__.__name__} sudo chown", + chown_proc.stdout, + chown_proc.stderr, + ) return proc @remap_kwargs({"packages": "install_args"}) From d0aebe8dc2c340cb0ca5f5ee2655c2a0cb2305d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:21:09 +0000 Subject: [PATCH 12/30] All providers: never treat managed shims as source-of-truth for load() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core principle (per maintainer): the symlinks/shims abxpkg writes under ``bin_dir`` are a human-convenience side-effect of install, not a source of truth. ``load()`` and every other internal lookup must always consult the underlying package manager / CLI instead. Audit result — only three providers had a "shim-first" short-circuit in ``default_abspath_handler``; the others (``docker``, ``bash`` wrappers; ``npm``/``pip``/``pnpm``/``yarn``/``uv``/``cargo``/``gem``/ ``goget`` where bin_dir IS the package manager's install location, not a shim of something upstream) were already correct. Fixed: - ``BrewProvider``: removed the initial ``bin_abspath(bin_name, PATH=str(self.bin_dir))`` short-circuit. Now always computes the brew Cellar / opt / PATH search list first and only refreshes the managed shim to match the freshly-resolved target. - ``PuppeteerProvider``: removed the ``bin_dir/`` shim check before falling back to ``puppeteer-browsers list``. Now always asks the CLI first, then refreshes the shim to point at the fresh path. - ``PlaywrightProvider``: same — always asks ``playwright-core``'s ``executablePath()`` first, shim becomes output-only. Also updated the uninstall-handler docstrings for both puppeteer and playwright to reflect that dropping the shim is a cosmetic cleanup (to keep managed PATH tidy) rather than a correctness requirement for ``load()``. --- abxpkg/binprovider_brew.py | 12 ++++++------ abxpkg/binprovider_playwright.py | 16 ++++++++++------ abxpkg/binprovider_puppeteer.py | 21 ++++++++++++++------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index da8b0ec3..99c4172d 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -396,15 +396,15 @@ def default_abspath_handler( if not self.PATH: return None - linked_bin = self._linked_bin_path(bin_name) - if linked_bin is not None: - linked_abspath = bin_abspath(bin_name, PATH=str(self.bin_dir)) - if linked_abspath: - return linked_abspath - + # Authoritative lookup: search brew's own Cellar / opt / PATH + # entries for the real formula binary. The managed ``bin_dir`` + # shim is a convenience side-effect of install — never a source + # of truth — so we always consult brew's paths first and only + # refresh the shim to match the freshly-resolved target. search_paths = self._brew_search_paths(bin_name, no_cache=no_cache) abspath = bin_abspath(bin_name, PATH=search_paths) if abspath: + linked_bin = self._linked_bin_path(bin_name) if linked_bin is None or Path(abspath).parent == self.bin_dir: return abspath return self._refresh_bin_link(bin_name, abspath) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 23de7302..3b368f4b 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -564,10 +564,11 @@ def default_abspath_handler( except Exception: return None return None - if self.bin_dir is not None: - link = self.bin_dir / str(bin_name) - if link.exists() and os.access(link, os.X_OK): - return link + # Authoritative lookup: ask ``playwright-core`` where the browser + # actually lives via its ``executablePath()`` API — never trust + # the managed ``bin_dir`` shim as a source of truth, it may + # point at a browser that was removed out-of-band. When + # playwright-core reports nothing, we report nothing. resolved = self._playwright_browser_path( str(bin_name), no_cache=no_cache, @@ -725,8 +726,11 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - # Drop the managed shim first so ``load()`` stops returning the - # symlink even if the browser-dir rmtree partially fails. + # Clean up the convenience shim under ``bin_dir`` alongside the + # real browser removal. ``load()`` never consults this shim as a + # source of truth (it always asks playwright-core directly), so + # dropping it is a cosmetic cleanup to keep the managed PATH + # tidy rather than a correctness requirement. if self.bin_dir is not None: (self.bin_dir / bin_name).unlink(missing_ok=True) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 59a3d6a9..d246ddcb 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -419,15 +419,19 @@ def default_abspath_handler( except Exception: return None return None - bin_dir = self.bin_dir - assert bin_dir is not None - link_path = bin_dir / str(bin_name) - if link_path.exists() and os.access(link_path, os.X_OK): - return link_path + # Authoritative lookup: ask puppeteer-browsers where the browser + # actually lives — never trust the managed ``bin_dir`` shim as a + # source of truth, it may point at a browser that was removed + # out-of-band. When the CLI reports nothing, we report nothing. resolved = self._resolve_installed_browser_path(str(bin_name)) if not resolved or not resolved.exists(): return None + + # Refresh the convenience shim under ``bin_dir`` so ``PATH`` users + # get a stable entry pointing at the freshly-resolved executable. + # Fall back to the resolved path directly when the shim refresh + # fails (read-only FS etc.). try: return self._refresh_symlink(str(bin_name), resolved) except OSError: @@ -688,8 +692,11 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - # Drop the managed shim first so ``load()`` stops returning the - # symlink even if the browser-dir rmtree partially fails. + # Clean up the convenience shim under ``bin_dir`` alongside the + # real browser removal. ``load()`` never consults this shim as a + # source of truth (it always asks puppeteer-browsers directly), + # so dropping it is a cosmetic cleanup to keep the managed PATH + # tidy rather than a correctness requirement. if self.bin_dir is not None: bin_path = self.bin_dir / bin_name if bin_path.exists() or bin_path.is_symlink(): From 5c8f9e48286d0d5cac6bb1cafb88a70bb6215e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:23:39 +0000 Subject: [PATCH 13/30] Fix CI regressions from cache_dir removal + brew shim-first cleanup - ``BrewProvider.default_abspath_handler``: ``linked_bin`` needs to be resolved at the top of the function since the fallback (``brew list --formula ...``) branch at line ~438 still references it. Hoist it back above the cellar/opt search rather than inside the ``if abspath:`` block. - ``tests/test_puppeteerprovider.py``: replace ``provider.cache_dir`` with the inline ``provider.install_root / "cache"`` now that the field has been removed. --- abxpkg/binprovider_brew.py | 2 +- tests/test_puppeteerprovider.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 99c4172d..8b77a664 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -401,10 +401,10 @@ def default_abspath_handler( # shim is a convenience side-effect of install — never a source # of truth — so we always consult brew's paths first and only # refresh the shim to match the freshly-resolved target. + linked_bin = self._linked_bin_path(bin_name) search_paths = self._brew_search_paths(bin_name, no_cache=no_cache) abspath = bin_abspath(bin_name, PATH=search_paths) if abspath: - linked_bin = self._linked_bin_path(bin_name) if linked_bin is None or Path(abspath).parent == self.bin_dir: return abspath return self._refresh_bin_link(bin_name, abspath) diff --git a/tests/test_puppeteerprovider.py b/tests/test_puppeteerprovider.py index 88d4ceec..659544c3 100644 --- a/tests/test_puppeteerprovider.py +++ b/tests/test_puppeteerprovider.py @@ -32,9 +32,9 @@ def test_chrome_alias_installs_real_browser_binary(self, test_machine): assert "@latest" not in installed.name assert "@" not in installed.loaded_abspath.name bin_dir = provider.bin_dir - cache_dir = provider.cache_dir assert bin_dir is not None - assert cache_dir is not None + assert provider.install_root is not None + cache_dir = provider.install_root / "cache" assert installed.loaded_abspath.parent == bin_dir assert installed.loaded_abspath == bin_dir / "chrome" assert (cache_dir / "chromium").exists() From c19901cc6962b4c3915968a95a76289cbb2606db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:26:11 +0000 Subject: [PATCH 14/30] Puppeteer/Playwright uninstall: resolve path without round-tripping load() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pirate review caught the ordering bug I introduced: ``default_uninstall_handler`` unlinked the convenience shim first, then called ``load(bin_name)`` to find the real browser directory. But ``default_abspath_handler`` refreshes the managed shim to point at the freshly-resolved target, so by the time rmtree ran, the shim had been re-created pointing at the browser dir we were about to delete — leaving a dangling symlink afterwards. Swap the order: resolve the browser directory via the CLI directly (``_resolve_installed_browser_path`` for puppeteer, ``_playwright_browser_path`` for playwright), rmtree the browser dir, THEN drop the shim last. The internal resolvers don't touch bin_dir, so the shim only goes away when we explicitly unlink it. README: replace the stale Playwright note that claimed we "leave playwright's OS-default cache untouched when install_root is unset" with the actual current behaviour — uninstall rmtrees the resolved browser directory in both managed and passthrough modes, since the CLI's own uninstall has no per-browser argument. --- README.md | 2 +- abxpkg/binprovider_playwright.py | 44 ++++++++++++++------------------ abxpkg/binprovider_puppeteer.py | 43 ++++++++++++++----------------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index db976579..3b647b67 100644 --- a/README.md +++ b/README.md @@ -1106,7 +1106,7 @@ euid = 0 # routes exec() through sudo-first-then-fallbac - Privilege handling: `--with-deps` installs system packages and requires root on Linux. ``euid`` defaults to ``0``, which routes every ``exec()`` call through the base ``BinProvider.exec`` sudo-first-then-fallback path — it tries ``sudo -n -- playwright install --with-deps ...`` first on non-root hosts, falls back to running the command directly if sudo fails or isn't available, and merges both stderr outputs into the final error if both attempts fail. - Security: `min_release_age` and `postinstall_scripts=False` are unsupported for browser installs and are ignored with a warning if explicitly requested. - Overrides: `install_args` are appended onto `playwright install` after `playwright_install_args` (defaults to `["--with-deps"]`) and passed through verbatim — use whatever browser names / flags the `playwright install` CLI accepts (`chromium`, `firefox`, `webkit`, `--no-shell`, `--only-shell`, `--force`, etc.). -- Notes: `update()` bumps the `playwright` npm package in `install_root` first (via `NpmProvider.update`) so its pinned browser versions refresh, then re-runs `playwright install --force ` to pull any new browser builds. `uninstall()` removes the relevant `-*/` directories from `install_root` alongside the bin-dir symlink, since `playwright uninstall` only drops *unused* browsers on its own. Both `update()` and `uninstall()` leave playwright's OS-default cache untouched when `install_root` is unset. +- Notes: `update()` bumps the `playwright` npm package in `install_root` first (via `NpmProvider.update`) so its pinned browser versions refresh, then re-runs `playwright install --force ` to pull any new browser builds. `uninstall()` resolves the browser's real install directory via `playwright-core`'s `executablePath()`, walks up to the containing `-/` dir, and rmtrees that dir — in both managed and passthrough modes — because `playwright uninstall` itself has no per-browser argument and only drops *unused* browsers wholesale. diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 3b368f4b..19a3f269 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -726,35 +726,29 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - # Clean up the convenience shim under ``bin_dir`` alongside the - # real browser removal. ``load()`` never consults this shim as a - # source of truth (it always asks playwright-core directly), so - # dropping it is a cosmetic cleanup to keep the managed PATH - # tidy rather than a correctness requirement. - if self.bin_dir is not None: - (self.bin_dir / bin_name).unlink(missing_ok=True) - - # Use ``load()`` to resolve the actual installed browser - # executable — ``playwright-core``'s ``executablePath()`` reads - # ``PLAYWRIGHT_BROWSERS_PATH`` from the subprocess env, which - # the provider exports when ``install_root`` is set and which - # otherwise passes through from the ambient env. This single - # call covers managed, OS-default, and user-env-var modes. - # Then walk up from that abspath to find the - # ``-/`` dir and rmtree it — playwright's - # own ``uninstall`` CLI has no per-browser argument, so this - # is still the only way to remove a specific browser. - try: - loaded = self.load(bin_name, quiet=True, no_cache=True) - except Exception: - loaded = None - loaded_abspath = loaded.loaded_abspath if loaded else None - if loaded_abspath is not None: - for parent in Path(loaded_abspath).resolve().parents: + # Resolve the real browser directory via playwright-core's + # ``executablePath()`` directly (``_playwright_browser_path`` + # shells out to the node API) so we don't round-trip through + # ``load()`` → ``default_abspath_handler`` and have it refresh + # the managed shim right as we're about to delete it. Honours + # managed ``install_root``, ambient ``PLAYWRIGHT_BROWSERS_PATH``, + # and playwright's own default (``~/.cache/ms-playwright``) + # uniformly. + resolved = self._playwright_browser_path(str(bin_name), no_cache=True) + if resolved is not None: + for parent in Path(resolved).resolve().parents: if parent.name.startswith(f"{bin_name}-"): logger.info("$ %s", format_command(["rm", "-rf", str(parent)])) shutil.rmtree(parent, ignore_errors=True) break + + # Finally, drop the convenience shim under ``bin_dir``. Doing + # this last avoids the "unlink → load() refresh → rmtree → + # dangling shim" ordering bug (playwright's own CLI has no + # per-browser uninstall argument, so this rmtree dance is + # still the only way to remove a specific browser). + if self.bin_dir is not None: + (self.bin_dir / bin_name).unlink(missing_ok=True) return True diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index d246ddcb..bf29a227 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -692,34 +692,29 @@ def default_uninstall_handler( install_args: InstallArgs | None = None, **context, ) -> bool: - # Clean up the convenience shim under ``bin_dir`` alongside the - # real browser removal. ``load()`` never consults this shim as a - # source of truth (it always asks puppeteer-browsers directly), - # so dropping it is a cosmetic cleanup to keep the managed PATH - # tidy rather than a correctness requirement. - if self.bin_dir is not None: - bin_path = self.bin_dir / bin_name - if bin_path.exists() or bin_path.is_symlink(): - logger.info("$ %s", format_command(["rm", "-f", str(bin_path)])) - bin_path.unlink(missing_ok=True) - - # Use ``load()`` to resolve the actual installed browser - # executable — load honours managed install_root, the ambient - # PUPPETEER_CACHE_DIR env var, and puppeteer-browsers' own - # default, so this single call handles all three cases. Then - # walk up from that abspath to find the browser's top-level - # directory (``//``) and rmtree it. + # Resolve the real browser directory via the CLI directly + # (``_resolve_installed_browser_path`` shells out to + # ``puppeteer-browsers list``) so we don't round-trip through + # ``load()`` → ``default_abspath_handler`` and have it refresh + # the managed shim right as we're about to delete it. Honours + # managed ``install_root``, ambient ``PUPPETEER_CACHE_DIR``, + # and puppeteer-browsers' own default uniformly. install_args = list(install_args or self.get_install_args(bin_name)) browser_name = self._browser_name(bin_name, install_args) - try: - loaded = self.load(bin_name, quiet=True, no_cache=True) - except Exception: - loaded = None - loaded_abspath = loaded.loaded_abspath if loaded else None - if loaded_abspath is not None: - for parent in Path(loaded_abspath).resolve().parents: + resolved = self._resolve_installed_browser_path(str(bin_name)) + if resolved is not None: + for parent in Path(resolved).resolve().parents: if parent.name == browser_name: logger.info("$ %s", format_command(["rm", "-rf", str(parent)])) shutil.rmtree(parent, ignore_errors=True) break + + # Finally, drop the convenience shim under ``bin_dir``. Doing + # this last avoids the "unlink → load() refresh → rmtree → + # dangling shim" ordering bug. + if self.bin_dir is not None: + bin_path = self.bin_dir / bin_name + if bin_path.exists() or bin_path.is_symlink(): + logger.info("$ %s", format_command(["rm", "-f", str(bin_path)])) + bin_path.unlink(missing_ok=True) return True From 1788b1831cb6360b12f8dca9dee7b0aea32fe3d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:43:40 +0000 Subject: [PATCH 15/30] tests/test_playwrightprovider: assert browsers live under install_root/cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught the test drift from the PLAYWRIGHT_BROWSERS_PATH rework: browsers now land in ``/cache/chromium-/`` (matching the ``install_root/cache`` default abxpkg pins), not directly under ``install_root``. Update every place that iterates ``playwright_root`` or ``install_root`` looking for ``chromium-*/`` dirs to walk ``/cache`` instead — covering the chromium install lifecycle assertions, the ``--no-shell`` carveout check, the update test's resolved-target ancestry check, and the dry-run "no browsers got downloaded" check. Also points the symlink ancestry assertion in ``test_chromium_install_puts_real_browser_into_managed_bin_dir`` at ``playwright_root/cache`` since that's the actual ``PLAYWRIGHT_BROWSERS_PATH`` root the symlinks resolve into. --- tests/test_playwrightprovider.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_playwrightprovider.py b/tests/test_playwrightprovider.py index 45deda7b..eb2c7249 100644 --- a/tests/test_playwrightprovider.py +++ b/tests/test_playwrightprovider.py @@ -70,14 +70,14 @@ def test_chromium_install_puts_real_browser_into_managed_bin_dir( assert provider.bin_dir is not None assert installed.loaded_abspath.parent == provider.bin_dir assert installed.loaded_abspath == provider.bin_dir / "chromium" - # The symlink resolves into playwright_root (which is also - # PLAYWRIGHT_BROWSERS_PATH for this provider). + # The symlink resolves into ``playwright_root/cache`` (the + # managed ``PLAYWRIGHT_BROWSERS_PATH`` for this provider). real_target = installed.loaded_abspath.resolve() - assert playwright_root.resolve() in real_target.parents + assert (playwright_root / "cache").resolve() in real_target.parents # Playwright lays out chromium builds as chromium-/. assert any( child.name.startswith("chromium-") - for child in playwright_root.iterdir() + for child in (playwright_root / "cache").iterdir() if child.is_dir() ) @@ -134,7 +134,7 @@ def test_install_root_alias_without_explicit_bin_dir_uses_root_bin( # the effective PLAYWRIGHT_BROWSERS_PATH for this provider). assert any( child.name.startswith("chromium-") - for child in install_root.iterdir() + for child in (install_root / "cache").iterdir() if child.is_dir() ) @@ -171,7 +171,7 @@ def test_install_root_and_bin_dir_aliases_install_into_the_requested_paths( # Browser tree still landed in install_root, not bin_dir. assert any( child.name.startswith("chromium-") - for child in install_root.iterdir() + for child in (install_root / "cache").iterdir() if child.is_dir() ) @@ -244,9 +244,10 @@ def test_provider_install_args_are_passed_through_to_playwright_install( assert installed is not None assert installed.loaded_abspath is not None assert installed.loaded_abspath.exists() + cache_dir = playwright_root / "cache" chromium_dirs = [ child - for child in playwright_root.iterdir() + for child in cache_dir.iterdir() if child.is_dir() and child.name.startswith("chromium-") and not child.name.startswith("chromium_headless_shell") @@ -255,7 +256,7 @@ def test_provider_install_args_are_passed_through_to_playwright_install( # ``--no-shell`` should have skipped the headless shell download. headless_shell_dirs = [ child - for child in playwright_root.iterdir() + for child in cache_dir.iterdir() if child.is_dir() and child.name.startswith("chromium_headless_shell") ] assert not headless_shell_dirs, ( @@ -380,10 +381,10 @@ def test_update_refreshes_chromium_in_place( # ``playwright_root``). updated_target = updated.loaded_abspath.resolve() assert updated_target.exists() - assert playwright_root.resolve() in updated_target.parents + assert (playwright_root / "cache").resolve() in updated_target.parents assert any( child.name.startswith("chromium-") - for child in playwright_root.iterdir() + for child in (playwright_root / "cache").iterdir() if child.is_dir() ) @@ -397,14 +398,15 @@ def test_provider_dry_run_does_not_install_chromium(self, test_machine): test_machine.exercise_provider_dry_run(provider, bin_name="chromium") # dry_run must not have actually downloaded any browsers. + cache_dir = playwright_root / "cache" browser_dirs = ( [ p - for p in playwright_root.iterdir() + for p in cache_dir.iterdir() if p.is_dir() and p.name.startswith(("chromium-", "firefox-", "webkit-")) ] - if playwright_root.is_dir() + if cache_dir.is_dir() else [] ) assert not browser_dirs, ( From 6885e3a7d5f54c317610f85e3d62e13beb1d2c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:45:11 +0000 Subject: [PATCH 16/30] Puppeteer abspath_handler: guard _refresh_symlink when bin_dir is None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught the parity bug I introduced: ``_refresh_symlink`` asserts ``bin_dir is not None``, but in global/unmanaged mode (``install_root=None``) ``bin_dir`` is ``None`` and the ``except OSError`` doesn't catch the resulting ``AssertionError`` — crashing ``default_abspath_handler`` instead of returning the resolved path. Add the same early-return guard ``PlaywrightProvider`` already has: when ``bin_dir`` is ``None`` we return ``resolved`` directly without attempting a shim refresh. --- abxpkg/binprovider_puppeteer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index bf29a227..4e83945d 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -430,8 +430,12 @@ def default_abspath_handler( # Refresh the convenience shim under ``bin_dir`` so ``PATH`` users # get a stable entry pointing at the freshly-resolved executable. - # Fall back to the resolved path directly when the shim refresh - # fails (read-only FS etc.). + # In global/unmanaged mode (``install_root=None``) we have no + # managed shim dir, so just return the resolved path directly. + # When the shim refresh fails (read-only FS etc.) we also fall + # back to the resolved path. + if self.bin_dir is None: + return resolved try: return self._refresh_symlink(str(bin_name), resolved) except OSError: From 9be4a6ce2b4324080deb3ec67f1f0563e9757da7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:49:30 +0000 Subject: [PATCH 17/30] Puppeteer/Playwright: auto-apply sandbox NO_PROXY default when ambient routes Google domains direct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ``CLAUDE_SANDBOX_NO_PROXY`` constant has been sitting in the PuppeteerProvider as a post-failure hint only; in sandboxed envs (Claude, some CI runners) the ambient ``NO_PROXY`` includes ``.googleapis.com`` / ``.google.com``, which tells both ``@puppeteer/browsers`` and ``playwright install`` to bypass the egress proxy when fetching from ``storage.googleapis.com`` / ``cdn.playwright.dev`` — and the direct connection then fails DNS or returns 503 because sandbox egress only works through the proxy. Wire the same sandbox-safe allowlist into both providers' ``ENV``: - If ``NO_PROXY`` / ``no_proxy`` is unset or contains ``.googleapis.com`` / ``.google.com``, replace it in the subprocess env with ``CLAUDE_SANDBOX_NO_PROXY`` (localhost / metadata-service / cluster-local only) so the browser download goes through the proxy. - If the caller has a non-sandbox-problematic ``NO_PROXY`` (e.g. ``localhost,internal.corp``), pass it through unchanged — we only override the two known-bad patterns. Playwright imports the constant from PuppeteerProvider to avoid a second copy; both providers share the exact same allowlist so tests can assert one value. --- abxpkg/binprovider_playwright.py | 30 ++++++++++++++++++++++++------ abxpkg/binprovider_puppeteer.py | 28 ++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 19a3f269..b114b589 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -23,6 +23,7 @@ from .binary import Binary from .binprovider import BinProvider, EnvProvider, log_method_call, remap_kwargs from .binprovider_npm import NpmProvider +from .binprovider_puppeteer import CLAUDE_SANDBOX_NO_PROXY from .logging import format_command, format_subprocess_output, get_logger from .semver import SemVer @@ -81,12 +82,29 @@ class PlaywrightProvider(BinProvider): @property def ENV(self) -> "dict[str, str]": # In managed mode we pin ``PLAYWRIGHT_BROWSERS_PATH`` to - # ``/cache``. In unmanaged mode we export nothing - # and the ambient env (or playwright's own - # ``~/.cache/ms-playwright`` default) passes through untouched. - if self.install_root is None: - return {} - return {"PLAYWRIGHT_BROWSERS_PATH": str(self.install_root / "cache")} + # ``/cache``. In unmanaged mode we leave the + # ambient env (or playwright's own ``~/.cache/ms-playwright`` + # default) untouched. + env: dict[str, str] = {} + if self.install_root is not None: + env["PLAYWRIGHT_BROWSERS_PATH"] = str(self.install_root / "cache") + # ``playwright install`` downloads browsers from + # ``cdn.playwright.dev`` (Azure blob storage) and + # ``storage.googleapis.com`` depending on the build. In + # sandboxed environments the egress proxy's NO_PROXY often + # includes ``.googleapis.com`` / ``.google.com``, which forces + # the direct connection — which then fails DNS resolution or + # times out. Mirror the PuppeteerProvider fix: override + # NO_PROXY / no_proxy to a safe sandbox allowlist so the + # download goes through the proxy instead. + ambient_no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") + if not ambient_no_proxy or ( + ".googleapis.com" in ambient_no_proxy.lower() + or ".google.com" in ambient_no_proxy.lower() + ): + env["NO_PROXY"] = CLAUDE_SANDBOX_NO_PROXY + env["no_proxy"] = CLAUDE_SANDBOX_NO_PROXY + return env def supports_min_release_age(self, action, no_cache: bool = False) -> bool: return False diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 4e83945d..2a9dccaa 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -71,12 +71,28 @@ class PuppeteerProvider(BinProvider): @property def ENV(self) -> "dict[str, str]": # In managed mode we pin ``PUPPETEER_CACHE_DIR`` to - # ``/cache``. In unmanaged mode we export nothing - # and the ambient env (or puppeteer-browsers' own - # ``~/.cache/puppeteer`` default) passes through untouched. - if self.install_root is None: - return {} - return {"PUPPETEER_CACHE_DIR": str(self.install_root / "cache")} + # ``/cache``. In unmanaged mode we leave the + # ambient env (or puppeteer-browsers' own ``~/.cache/puppeteer`` + # default) untouched. + env: dict[str, str] = {} + if self.install_root is not None: + env["PUPPETEER_CACHE_DIR"] = str(self.install_root / "cache") + # @puppeteer/browsers downloads browsers from + # storage.googleapis.com. In sandboxed environments the egress + # proxy's NO_PROXY often includes ``.googleapis.com`` / ``.google.com``, + # which forces the direct connection — which then fails DNS + # resolution or times out. Override NO_PROXY / no_proxy to a + # safe sandbox allowlist so the download goes through the proxy + # instead. Callers that need their own NO_PROXY can still set it + # via the CLI override flags; our value only fills in the default. + ambient_no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") + if not ambient_no_proxy or ( + ".googleapis.com" in ambient_no_proxy.lower() + or ".google.com" in ambient_no_proxy.lower() + ): + env["NO_PROXY"] = CLAUDE_SANDBOX_NO_PROXY + env["no_proxy"] = CLAUDE_SANDBOX_NO_PROXY + return env def supports_postinstall_disable(self, action, no_cache: bool = False) -> bool: return action in ("install", "update") From 72c1825ac0392efdf86c1f8aa3eb6d76a1748afe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 23:59:29 +0000 Subject: [PATCH 18/30] Puppeteer _resolve_installed_browser_path: resolve alias via configured install_args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the shim-first short-circuit was removed from ``default_abspath_handler``, ``load()`` started going through ``_resolve_installed_browser_path`` for every query. That helper computed the canonical browser name from ``bin_name + install_args``, but ``default_abspath_handler`` didn't forward any ``install_args``, so it fell back to ``[bin_name]`` — which meant ``load("chrome")`` looked for ``puppeteer-browsers list`` entries whose browser name was ``chrome``, missing installs that landed as ``chromium@latest`` via a ``{"chrome": {"install_args": ["chromium@latest"]}}`` override (exactly the scenario ``test_chrome_alias_installs_real_browser_binary`` exercises). Fix: when ``install_args`` isn't passed explicitly, fall back to ``self.get_install_args(bin_name)`` so the provider's own handler-overrides map the alias (``chrome`` → ``chromium@latest``) before ``_browser_name`` extracts the canonical package name. --- abxpkg/binprovider_puppeteer.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 2a9dccaa..89f7e0fc 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -384,7 +384,14 @@ def _resolve_installed_browser_path( install_args: Iterable[str] | None = None, no_cache: bool = False, ) -> Path | None: - browser_name = self._browser_name(bin_name, install_args or [bin_name]) + # Pick up the caller's configured install_args so + # ``bin_name=chrome`` + ``install_args=["chromium@latest"]`` + # resolves to ``browser_name="chromium"`` (matching what + # ``puppeteer-browsers list`` reports), instead of falling + # back to ``[bin_name]`` which would look for the alias name. + if install_args is None: + install_args = self.get_install_args(bin_name, quiet=True) or [bin_name] + browser_name = self._browser_name(bin_name, install_args) candidates = [ (version, path) for candidate_browser, version, path in self._list_installed_browsers( From ef933820d86a5b03af56a65103a38453c459de0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 00:28:06 +0000 Subject: [PATCH 19/30] Puppeteer _resolve_installed_browser_path: fall back to newest mtime when no SemVer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium build IDs (e.g. ``1618494``) aren't valid SemVer, so ``SemVer.parse`` returns ``None`` for all candidates. When two builds end up in the cache (typical ``chromium@latest`` reinstall flow: original build + newly-resolved ``latest``) we fell through to ``return None`` because ``parsed_candidates=[]`` and ``len(candidates) > 1``. ``provider.install('chrome', no_cache=True)`` then failed the post-install load with "Installed package did not produce runnable binary 'chrome'" — exactly the CI failure on ``test_chrome_alias_installs_real_browser_binary``. Add a deterministic fallback: sort unparseable candidates by file mtime and pick the newest, so a ``latest`` reinstall still resolves to the freshly-downloaded build instead of bailing out. --- abxpkg/binprovider_puppeteer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 89f7e0fc..a9d094c7 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -406,9 +406,21 @@ def _resolve_installed_browser_path( ] if parsed_candidates: return max(parsed_candidates, key=lambda item: item[0])[1] + if not candidates: + return None if len(candidates) == 1: return candidates[0][1] - return None + # Multiple cached builds but none parse as ``SemVer`` (e.g. chromium's + # integer build IDs like ``1618539``). Fall back to the newest one + # by file mtime so post-install lookups land on the freshly- + # downloaded version rather than ``None``. + candidates.sort( + key=lambda item: ( + item[1].stat().st_mtime if item[1].exists() else 0, + item[0], + ), + ) + return candidates[-1][1] def _refresh_symlink(self, bin_name: str, target: Path) -> Path: bin_dir = self.bin_dir From c58da69ed3950a9ef520538cc9d2c1fb779b229e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 01:13:36 +0000 Subject: [PATCH 20/30] Puppeteer INSTALLER_BINARY: bootstrap from install_root/npm/node_modules/.bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh provider copies built by Binary.load() via get_provider_with_overrides start with _INSTALLER_BINARY=None, so super().INSTALLER_BINARY() falls through to searching the env/brew installer providers — none of which know about our hermetic /npm/node_modules/.bin layout. The install itself writes puppeteer-browsers there, but subsequent load()s couldn't find it, making _list_installed_browsers() silently return empty and default_abspath_handler return None → Binary.load() raises `BinaryLoadError: ERRORS={}`. Mirror PlaywrightProvider's local_cli fallback: check the hermetic npm bin first, then wrap the result in an EnvProvider.load() and persist it via write_cached_binary so repeat lookups are cheap. https://claude.ai/code/session_01Xt1YPCMzQrrFihDgVEYy4S --- abxpkg/binprovider_puppeteer.py | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index a9d094c7..b2239605 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -126,6 +126,70 @@ def setup_PATH(self, no_cache: bool = False) -> None: def INSTALLER_BINARY(self, no_cache: bool = False): from . import DEFAULT_PROVIDER_NAMES, PROVIDER_CLASS_BY_NAME + # Prefer the puppeteer-browsers bootstrapped by an earlier install + # under ``/npm/node_modules/.bin``. Without this, a + # fresh provider copy (e.g. the one Binary.load() builds via + # get_provider_with_overrides) can't locate puppeteer-browsers and + # ``_list_installed_browsers()`` silently returns empty. + lib_dir = os.environ.get("ABXPKG_LIB_DIR") + if ( + self.install_root is not None + and lib_dir + and str(self.install_root).startswith(lib_dir.rstrip("/") + "/") + ): + local_cli = ( + Path(lib_dir) / "npm" / "node_modules" / ".bin" / self.INSTALLER_BIN + ) + elif self.install_root is not None: + local_cli = ( + self.install_root / "npm" / "node_modules" / ".bin" / self.INSTALLER_BIN + ) + else: + local_cli = None + + if ( + local_cli is not None + and local_cli.is_file() + and os.access(local_cli, os.X_OK) + ): + if ( + not no_cache + and self._INSTALLER_BINARY + and self._INSTALLER_BINARY.loaded_abspath == local_cli + and self._INSTALLER_BINARY.is_valid + ): + return self._INSTALLER_BINARY + if not no_cache: + cached = self.load_cached_binary(self.INSTALLER_BIN, local_cli) + if cached and cached.loaded_abspath: + self._INSTALLER_BINARY = cached + return cached + env_provider = EnvProvider( + PATH=str(local_cli.parent), + install_root=None, + bin_dir=None, + ) + loaded_local = env_provider.load( + bin_name=self.INSTALLER_BIN, + no_cache=no_cache, + ) + if loaded_local and loaded_local.loaded_abspath: + if loaded_local.loaded_version and loaded_local.loaded_sha256: + self.write_cached_binary( + self.INSTALLER_BIN, + loaded_local.loaded_abspath, + loaded_local.loaded_version, + loaded_local.loaded_sha256, + resolved_provider_name=( + loaded_local.loaded_binprovider.name + if loaded_local.loaded_binprovider is not None + else self.name + ), + cache_kind="dependency", + ) + self._INSTALLER_BINARY = loaded_local + return self._INSTALLER_BINARY + loaded = super().INSTALLER_BINARY(no_cache=no_cache) raw_provider_names = os.environ.get("ABXPKG_BINPROVIDERS") selected_provider_names = ( From 38982f99d998fced31bbd8fefa504888035f5ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:02:50 +0000 Subject: [PATCH 21/30] PlaywrightProvider: self-contain CLAUDE_SANDBOX_NO_PROXY + forward via sudo env wrapper Two Devin review findings: 1. binprovider_playwright.py imported CLAUDE_SANDBOX_NO_PROXY from binprovider_puppeteer.py, violating the "each binprovider owns its own provider-specific logic" rule in AGENTS.md. Define the constant locally instead. 2. exec() already wraps commands with /usr/bin/env KEY=VAL so that PLAYWRIGHT_BROWSERS_PATH survives sudo's env_reset, but the NO_PROXY / no_proxy values set by ENV were not being forwarded via the same mechanism -- sudo stripped them before reaching playwright, defeating the sandbox NO_PROXY fix on Linux (where euid=0 routes every subprocess through sudo -n). Forward NO_PROXY/no_proxy as CLI-arg assignments alongside PLAYWRIGHT_BROWSERS_PATH. --- abxpkg/binprovider_playwright.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index b114b589..f02fdf9e 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -23,12 +23,16 @@ from .binary import Binary from .binprovider import BinProvider, EnvProvider, log_method_call, remap_kwargs from .binprovider_npm import NpmProvider -from .binprovider_puppeteer import CLAUDE_SANDBOX_NO_PROXY from .logging import format_command, format_subprocess_output, get_logger from .semver import SemVer logger = get_logger(__name__) +CLAUDE_SANDBOX_NO_PROXY = ( + "localhost,127.0.0.1,169.254.169.254,metadata.google.internal," + ".svc.cluster.local,.local" +) + class PlaywrightProvider(BinProvider): """Playwright browser installer provider. @@ -319,6 +323,15 @@ def exec( env_assignments.append( f"PLAYWRIGHT_BROWSERS_PATH={cache_dir}", ) + # ``NO_PROXY`` / ``no_proxy`` are set by ``ENV`` to rescue + # browser downloads in sandboxes whose egress proxy NO_PROXY + # blocks ``cdn.playwright.dev`` / ``storage.googleapis.com``. + # They must also survive sudo's ``env_reset``, so forward them + # through the ``/usr/bin/env KEY=VAL -- ...`` wrapper below. + for proxy_key in ("NO_PROXY", "no_proxy"): + proxy_value = env.get(proxy_key) + if proxy_value: + env_assignments.append(f"{proxy_key}={proxy_value}") needs_sudo_env_wrapper = os.geteuid() != 0 and self.EUID != os.geteuid() if env_assignments and needs_sudo_env_wrapper: resolved_bin = bin_name From b47f7bb3d5b03e59b72b166cdd90f9ab3f331db1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 16:27:04 +0000 Subject: [PATCH 22/30] Puppeteer/Playwright _refresh_symlink: idempotent when shim already matches On macOS, chrome/chromium ship as ``.app`` bundles so the managed ``bin_dir`` shim is a shell-script launcher (``#!/bin/sh\nexec ``), not a symlink. ``default_abspath_handler`` calls ``_refresh_symlink`` on every ``load()``, which unconditionally deleted + rewrote the shim -- bumping its mtime each time. Tests that call ``install()`` then ``assert_shallow_binary_loaded`` (which re-stats the shim via ``get_abspath(no_cache=True)``) then observed ``loaded_mtime != resolved.stat().st_mtime_ns``, failing across every macOS puppeteer/ playwright test (8 playwright tests + test_chrome_alias in puppeteer). Linux uses a real symlink and ``.resolve().stat()`` follows it to the browser binary, whose mtime is stable -- so Linux CI was already green. Skip the unlink+rewrite when the existing shim already points at ``target`` (symlink target equals path, or shell-script contents match what we'd write). ``install()`` still always resolves the fresh path, and a stale shim pointing at an old target still gets replaced. --- abxpkg/binprovider_playwright.py | 34 +++++++++++++++++++++++++------- abxpkg/binprovider_puppeteer.py | 30 +++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index f02fdf9e..496eea42 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -561,16 +561,36 @@ def _refresh_symlink(self, bin_name: str, target: Path) -> Path: ) link = self.bin_dir / bin_name link.parent.mkdir(parents=True, exist_ok=True) - if link.exists() or link.is_symlink(): - link.unlink(missing_ok=True) # On macOS the executable is buried inside a ``.app`` bundle, so # write a tiny shell shim instead of a symlink (same pattern as # PuppeteerProvider). - if os.name == "posix" and ".app/Contents/MacOS/" in str(target): - link.write_text( - f'#!/bin/sh\nexec {shlex.quote(str(target))} "$@"\n', - encoding="utf-8", - ) + use_shell_shim = os.name == "posix" and ".app/Contents/MacOS/" in str(target) + desired_script = ( + f'#!/bin/sh\nexec {shlex.quote(str(target))} "$@"\n' + if use_shell_shim + else None + ) + # Idempotent refresh: leave the shim untouched when it already + # points at ``target``. Rewriting on every ``load()`` would bump + # the shim's mtime (shell-script case), which breaks callers + # that stat the shim to validate a freshly-installed binary. + if link.is_symlink(): + try: + if os.readlink(link) == str(target): + return link + except OSError: + pass + elif use_shell_shim and link.is_file(): + try: + if link.read_text(encoding="utf-8") == desired_script: + return link + except OSError: + pass + if link.exists() or link.is_symlink(): + link.unlink(missing_ok=True) + if use_shell_shim: + assert desired_script is not None + link.write_text(desired_script, encoding="utf-8") link.chmod(0o755) return link link.symlink_to(target) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index b2239605..aa429252 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -491,13 +491,33 @@ def _refresh_symlink(self, bin_name: str, target: Path) -> Path: assert bin_dir is not None link_path = bin_dir / bin_name link_path.parent.mkdir(parents=True, exist_ok=True) + use_shell_shim = os.name == "posix" and ".app/Contents/MacOS/" in str(target) + desired_script = ( + f'#!/bin/sh\nexec {shlex.quote(str(target))} "$@"\n' + if use_shell_shim + else None + ) + # Idempotent refresh: leave the shim untouched when it already + # points at ``target``. Rewriting on every ``load()`` would bump + # the shim's mtime (shell-script case), which breaks callers + # that stat the shim to validate a freshly-installed binary. + if link_path.is_symlink(): + try: + if os.readlink(link_path) == str(target): + return link_path + except OSError: + pass + elif use_shell_shim and link_path.is_file(): + try: + if link_path.read_text(encoding="utf-8") == desired_script: + return link_path + except OSError: + pass if link_path.exists() or link_path.is_symlink(): link_path.unlink(missing_ok=True) - if os.name == "posix" and ".app/Contents/MacOS/" in str(target): - link_path.write_text( - f'#!/bin/sh\nexec {shlex.quote(str(target))} "$@"\n', - encoding="utf-8", - ) + if use_shell_shim: + assert desired_script is not None + link_path.write_text(desired_script, encoding="utf-8") link_path.chmod(0o755) return link_path link_path.symlink_to(target) From 7cbe28172641182084bd5d1dc982fa114e30deef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 16:29:58 +0000 Subject: [PATCH 23/30] All providers: make managed shim refresh idempotent Brew / Npm / Pnpm / Goget providers all refreshed their managed ``bin_dir`` shim on every ``default_abspath_handler`` call (i.e. every ``load()``) by unconditionally ``unlink`` + ``symlink_to``. Even for symlinks -- where ``.resolve().stat()`` follows the link and returns the target's stable mtime -- this still churns the shim's inode on every load, which invalidates fingerprint caches keyed on inode and causes unnecessary filesystem thrash in tight lifecycle loops. Skip the unlink + rewrite when the existing symlink already resolves to ``target``. Matches the idempotency check already present in the base class ``_link_loaded_binary`` and the same fix just applied to Puppeteer/Playwright ``_refresh_symlink``. --- abxpkg/binprovider_brew.py | 9 +++++++++ abxpkg/binprovider_goget.py | 9 +++++++++ abxpkg/binprovider_npm.py | 9 +++++++++ abxpkg/binprovider_pnpm.py | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 8b77a664..aa84f422 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -183,6 +183,15 @@ def _refresh_bin_link( except Exception: break walk_path = walk_path.parent + # Idempotent refresh: skip when shim already points at target. + # Rewriting on every load() bumps mtime and churns the inode, + # which invalidates fingerprint caches unnecessarily. + if link_path.is_symlink(): + try: + if link_path.readlink() == Path(target): + return TypeAdapter(HostBinPath).validate_python(link_path) + except OSError: + pass if link_path.exists() or link_path.is_symlink(): link_path.unlink(missing_ok=True) link_path.symlink_to(target) diff --git a/abxpkg/binprovider_goget.py b/abxpkg/binprovider_goget.py index 138689a9..5e0f1181 100755 --- a/abxpkg/binprovider_goget.py +++ b/abxpkg/binprovider_goget.py @@ -294,6 +294,15 @@ def default_abspath_handler( assert bin_dir is not None link_path = bin_dir / str(bin_name) link_path.parent.mkdir(parents=True, exist_ok=True) + # Idempotent refresh: skip when shim already points at target. + # Rewriting on every load() bumps mtime and churns the inode, + # which invalidates fingerprint caches unnecessarily. + if link_path.is_symlink(): + try: + if link_path.readlink() == Path(direct_abspath): + return TypeAdapter(HostBinPath).validate_python(link_path) + except OSError: + pass if link_path.exists() or link_path.is_symlink(): link_path.unlink(missing_ok=True) link_path.symlink_to(direct_abspath) diff --git a/abxpkg/binprovider_npm.py b/abxpkg/binprovider_npm.py index 75ccdf25..4c7ae341 100755 --- a/abxpkg/binprovider_npm.py +++ b/abxpkg/binprovider_npm.py @@ -377,6 +377,15 @@ def _refresh_bin_link( link_path = self._linked_bin_path(bin_name) assert link_path is not None, "_refresh_bin_link requires bin_dir to be set" link_path.parent.mkdir(parents=True, exist_ok=True) + # Idempotent refresh: skip when shim already points at target. + # Rewriting on every load() bumps mtime and churns the inode, + # which invalidates fingerprint caches unnecessarily. + if link_path.is_symlink(): + try: + if link_path.readlink() == Path(target): + return TypeAdapter(HostBinPath).validate_python(link_path) + except OSError: + pass if link_path.exists() or link_path.is_symlink(): link_path.unlink(missing_ok=True) link_path.symlink_to(target) diff --git a/abxpkg/binprovider_pnpm.py b/abxpkg/binprovider_pnpm.py index 634f73d9..87f1e150 100755 --- a/abxpkg/binprovider_pnpm.py +++ b/abxpkg/binprovider_pnpm.py @@ -274,6 +274,15 @@ def _refresh_bin_link( link_path = self._linked_bin_path(bin_name) assert link_path is not None, "_refresh_bin_link requires bin_dir to be set" link_path.parent.mkdir(parents=True, exist_ok=True) + # Idempotent refresh: skip when shim already points at target. + # Rewriting on every load() bumps mtime and churns the inode, + # which invalidates fingerprint caches unnecessarily. + if link_path.is_symlink(): + try: + if link_path.readlink() == Path(target): + return TypeAdapter(HostBinPath).validate_python(link_path) + except OSError: + pass if link_path.exists() or link_path.is_symlink(): link_path.unlink(missing_ok=True) link_path.symlink_to(target) From a464e53a61cbbaa4486868ef7a7e9fb7d7e59f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 16:50:36 +0000 Subject: [PATCH 24/30] PuppeteerProvider uninstall: forward install_args to browser-path resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``default_uninstall_handler`` derived ``browser_name`` from the caller's ``install_args`` but then called ``_resolve_installed_browser_path`` without forwarding them. The resolver re-runs ``get_install_args`` internally and, when the caller passed an alias that differs from the provider's registered overrides (e.g. ``chrome`` → ``chromium@latest``), picks a different ``browser_name`` than the one we just derived. The subsequent ``parent.name == browser_name`` match against the resolved path then fails silently, skipping the rmtree and leaking the browser cache to disk. Forward the already-resolved ``install_args`` so both derivations stay in sync. --- abxpkg/binprovider_puppeteer.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index aa429252..00faf4dc 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -824,7 +824,16 @@ def default_uninstall_handler( # and puppeteer-browsers' own default uniformly. install_args = list(install_args or self.get_install_args(bin_name)) browser_name = self._browser_name(bin_name, install_args) - resolved = self._resolve_installed_browser_path(str(bin_name)) + # Forward install_args so ``_resolve_installed_browser_path`` uses + # the same ``browser_name`` we just derived; otherwise it re-runs + # ``get_install_args`` and can pick up a different alias (e.g. + # caller-passed ``chromium@latest`` vs provider default ``chrome``), + # which would leave the parent.name match below unsatisfied and + # silently skip the rmtree. + resolved = self._resolve_installed_browser_path( + str(bin_name), + install_args=install_args, + ) if resolved is not None: for parent in Path(resolved).resolve().parents: if parent.name == browser_name: From 29d2fe57fc7ecdfbe9d3996b2a99463045157b86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 17:19:12 +0000 Subject: [PATCH 25/30] tests/playwright: resolve shim target through shell-script shims on macOS Two playwright tests assert ``loaded_abspath.resolve()`` lands inside ``playwright_root/cache``. On Linux the shim is a plain symlink so ``.resolve()`` follows naturally. On macOS chromium ships inside a ``.app`` bundle and the shim is a shell script (``exec ''``) instead -- ``.resolve()`` on a regular file just returns the file itself, failing the ``in updated_target.parents`` assertion. Add ``_resolve_shim_target()`` that falls back to parsing the ``exec`` line from the shell script when ``.resolve()`` doesn't follow. Also update ``copy_seeded_playwright_root()`` to rewrite the shell script's hardcoded absolute path from the seeded root to the per-test copy (previously it only repointed symlinks), so the pre-``load()`` shim state is consistent across both shim flavors. --- tests/test_playwrightprovider.py | 66 +++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/tests/test_playwrightprovider.py b/tests/test_playwrightprovider.py index eb2c7249..681002bd 100644 --- a/tests/test_playwrightprovider.py +++ b/tests/test_playwrightprovider.py @@ -1,4 +1,5 @@ import os +import re import shutil import tempfile from pathlib import Path @@ -8,6 +9,29 @@ from abxpkg import Binary, PlaywrightProvider +def _resolve_shim_target(shim: Path) -> Path: + """Resolve a managed bin_dir shim to its real browser target. + + On Linux the shim is a symlink, so ``.resolve()`` naturally follows + it. On macOS the shim is a shell script that ``exec``s the binary + inside a ``.app`` bundle (a symlink would leave the browser launching + without bundle context), so ``.resolve()`` just returns the script + path. Parse the ``exec `` line to recover the target in that + case. + """ + resolved = shim.resolve() + if resolved != shim: + return resolved + try: + script = shim.read_text(encoding="utf-8") + except OSError: + return resolved + match = re.search(r"exec '([^']+)'", script) + if not match: + return resolved + return Path(match.group(1)).resolve() + + @pytest.fixture(scope="module") def seeded_playwright_root(): with tempfile.TemporaryDirectory() as temp_dir: @@ -35,15 +59,37 @@ def copy_seeded_playwright_root( copied_bin_dir = install_root / "bin" if not copied_bin_dir.is_dir(): return + seeded_resolved = seeded_playwright_root.resolve() for link_path in copied_bin_dir.iterdir(): - if not link_path.is_symlink(): + if link_path.is_symlink(): + link_target = link_path.resolve(strict=False) + if seeded_resolved not in link_target.parents: + continue + relative_target = link_target.relative_to(seeded_resolved) + link_path.unlink() + link_path.symlink_to(install_root / relative_target) + continue + # macOS chrome/chromium shims are shell scripts that hardcode + # the seeded install_root path; rewrite them so they exec the + # copy under this test's install_root instead. + if not link_path.is_file(): continue - link_target = link_path.resolve(strict=False) - if seeded_playwright_root.resolve() not in link_target.parents: + try: + script = link_path.read_text(encoding="utf-8") + except OSError: continue - relative_target = link_target.relative_to(seeded_playwright_root.resolve()) - link_path.unlink() - link_path.symlink_to(install_root / relative_target) + match = re.search(r"exec '([^']+)'", script) + if not match: + continue + target_path = Path(match.group(1)) + if seeded_resolved not in target_path.resolve().parents: + continue + relative_target = target_path.resolve().relative_to(seeded_resolved) + new_target = install_root / relative_target + link_path.write_text( + script.replace(str(target_path), str(new_target)), + encoding="utf-8", + ) def test_chromium_install_puts_real_browser_into_managed_bin_dir( self, @@ -70,9 +116,9 @@ def test_chromium_install_puts_real_browser_into_managed_bin_dir( assert provider.bin_dir is not None assert installed.loaded_abspath.parent == provider.bin_dir assert installed.loaded_abspath == provider.bin_dir / "chromium" - # The symlink resolves into ``playwright_root/cache`` (the + # The shim resolves into ``playwright_root/cache`` (the # managed ``PLAYWRIGHT_BROWSERS_PATH`` for this provider). - real_target = installed.loaded_abspath.resolve() + real_target = _resolve_shim_target(installed.loaded_abspath) assert (playwright_root / "cache").resolve() in real_target.parents # Playwright lays out chromium builds as chromium-/. assert any( @@ -374,12 +420,12 @@ def test_update_refreshes_chromium_in_place( ) assert updated is not None assert updated.loaded_abspath is not None - # The symlink resolves to a chromium build that actually + # The shim resolves to a chromium build that actually # exists on disk after update (whether the build-id moved # depends on the current playwright release, but the # resolved target must always exist and still live inside # ``playwright_root``). - updated_target = updated.loaded_abspath.resolve() + updated_target = _resolve_shim_target(updated.loaded_abspath) assert updated_target.exists() assert (playwright_root / "cache").resolve() in updated_target.parents assert any( From 57e14c4f133b1b1904ab0de0ae7d253cc7cbeaa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:03:16 +0000 Subject: [PATCH 26/30] macOS test fix: key _resolve_shim_target off is_symlink + switch Linux CI to self-hosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ``tests/test_playwrightprovider.py::_resolve_shim_target``: on macOS ``$TMPDIR`` lives under ``/var/folders/...`` → ``/private/var/...``, so ``shim.resolve()`` always differs from ``shim`` even for a plain regular file. The previous ``resolved != shim`` short-circuit skipped the shell-script parsing and returned the shim path itself. Key the branch off ``is_symlink()`` instead so the shell-script case (macOS ``.app`` bundle, where a plain symlink would break dyld's ``@executable_path``-relative Framework loading) actually parses the ``exec ''`` line. 2. Switch Linux CI jobs (precheck, discover-*, build matrix Ubuntu entries, live-integration Linux entries, deploy-pages, release) from ``ubuntu-latest`` to the org's new self-hosted Linux runner. macOS matrix entries keep ``macOS-latest`` since the self-hosted runner is x64 Linux only. --- .github/workflows/deploy-pages.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/tests.yml | 14 +++++++------- tests/test_playwrightprovider.py | 20 +++++++++++--------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 71f11572..0fa4a0c1 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -16,7 +16,7 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: self-hosted timeout-minutes: 20 steps: - name: Checkout @@ -48,7 +48,7 @@ jobs: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest + runs-on: self-hosted needs: build timeout-minutes: 20 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5d2ec54..642b8838 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ concurrency: jobs: release-state: - runs-on: ubuntu-latest + runs-on: self-hosted timeout-minutes: 20 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be9ab1dc..aa03fcde 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ concurrency: jobs: precheck: - runs-on: ubuntu-latest + runs-on: self-hosted timeout-minutes: 20 steps: @@ -49,7 +49,7 @@ jobs: discover-standard-tests: needs: precheck - runs-on: ubuntu-latest + runs-on: self-hosted timeout-minutes: 20 outputs: test-files: ${{ steps.set-matrix.outputs.test-files }} @@ -88,9 +88,9 @@ jobs: max-parallel: 20 matrix: target: - - os: ubuntu-latest + - os: self-hosted python_version: '3.11' - - os: ubuntu-latest + - os: self-hosted python_version: '3.14' - os: macOS-latest python_version: '3.13' @@ -219,7 +219,7 @@ jobs: discover-live-tests: needs: precheck - runs-on: ubuntu-latest + runs-on: self-hosted timeout-minutes: 20 outputs: live-tests: ${{ steps.set-matrix.outputs.live-tests }} @@ -239,9 +239,9 @@ jobs: needs_docker=true fi - os_targets="ubuntu-latest macOS-latest" + os_targets="self-hosted macOS-latest" if grep -q "@pytest.mark.docker_required" "$test_file" || grep -q "@pytest.mark.root_required" "$test_file" || grep -q 'skipif("darwin"' "$test_file"; then - os_targets="ubuntu-latest" + os_targets="self-hosted" elif grep -q 'require_tool("brew")' "$test_file" && ! grep -q 'require_tool("apt-get")' "$test_file" && ! grep -q "operations.apt.packages" "$test_file" && ! grep -q "ansible.builtin.apt" "$test_file"; then os_targets="macOS-latest" fi diff --git a/tests/test_playwrightprovider.py b/tests/test_playwrightprovider.py index 681002bd..5831ad53 100644 --- a/tests/test_playwrightprovider.py +++ b/tests/test_playwrightprovider.py @@ -14,21 +14,23 @@ def _resolve_shim_target(shim: Path) -> Path: On Linux the shim is a symlink, so ``.resolve()`` naturally follows it. On macOS the shim is a shell script that ``exec``s the binary - inside a ``.app`` bundle (a symlink would leave the browser launching - without bundle context), so ``.resolve()`` just returns the script - path. Parse the ``exec `` line to recover the target in that - case. + inside a ``.app`` bundle (a direct symlink breaks dyld's + ``@executable_path``-relative Framework loading), so ``.resolve()`` + just returns the script path itself. Parse the ``exec `` line + to recover the target in that case. We key off ``is_symlink()`` + rather than comparing ``shim == shim.resolve()`` because macOS + ``$TMPDIR`` lives under ``/var/folders/...`` → ``/private/var/...``, + so ``resolve()`` always differs from the input even for plain files. """ - resolved = shim.resolve() - if resolved != shim: - return resolved + if shim.is_symlink(): + return shim.resolve() try: script = shim.read_text(encoding="utf-8") except OSError: - return resolved + return shim.resolve() match = re.search(r"exec '([^']+)'", script) if not match: - return resolved + return shim.resolve() return Path(match.group(1)).resolve() From 6d5bc99baea5a01224676fd99050c1d995410f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:04:59 +0000 Subject: [PATCH 27/30] CI: use runs-on group 'Default' so jobs can schedule on either GH-hosted or self-hosted runners Switch from the literal ``self-hosted`` label (which would only pick our self-hosted Linux runner) to ``runs-on: {group: Default}``. The Default runner group contains both GitHub-hosted runners and our new self-hosted x64 Linux runner, so the scheduler picks whichever is available first. - precheck / discover-standard-tests / discover-live-tests / deploy-pages / release: simple ``runs-on: {group: Default}``. - build matrix: Linux entries use ``os: {group: Default}`` (mapping values in matrix), macOS keeps the ``macOS-latest`` string. Added an ``os_name`` field for the job name since a mapping doesn't render nicely. ``runs-on: \${{ matrix.target.os }}`` evaluates to either the mapping or the string; GitHub Actions accepts both. - live-integration: discover script now emits ``os`` as either ``{"group":"Default"}`` or ``"macOS-latest"`` in the matrix JSON, and includes an ``os_name`` for display. --- .github/workflows/deploy-pages.yml | 6 ++++-- .github/workflows/release.yml | 3 ++- .github/workflows/tests.yml | 34 ++++++++++++++++++++---------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 0fa4a0c1..8db35041 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -16,7 +16,8 @@ concurrency: jobs: build: - runs-on: self-hosted + runs-on: + group: Default timeout-minutes: 20 steps: - name: Checkout @@ -48,7 +49,8 @@ jobs: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: self-hosted + runs-on: + group: Default needs: build timeout-minutes: 20 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 642b8838..efddf092 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,8 @@ concurrency: jobs: release-state: - runs-on: self-hosted + runs-on: + group: Default timeout-minutes: 20 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa03fcde..dfa60dbd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,8 @@ concurrency: jobs: precheck: - runs-on: self-hosted + runs-on: + group: Default timeout-minutes: 20 steps: @@ -49,7 +50,8 @@ jobs: discover-standard-tests: needs: precheck - runs-on: self-hosted + runs-on: + group: Default timeout-minutes: 20 outputs: test-files: ${{ steps.set-matrix.outputs.test-files }} @@ -78,7 +80,7 @@ jobs: echo "$json_array" build: - name: ${{ matrix.target.os }} py${{ matrix.target.python_version }} ${{ matrix.test.name }} + name: ${{ matrix.target.os_name }} py${{ matrix.target.python_version }} ${{ matrix.test.name }} needs: [precheck, discover-standard-tests] runs-on: ${{ matrix.target.os }} timeout-minutes: 20 @@ -88,11 +90,16 @@ jobs: max-parallel: 20 matrix: target: - - os: self-hosted + - os: + group: Default + os_name: linux python_version: '3.11' - - os: self-hosted + - os: + group: Default + os_name: linux python_version: '3.14' - os: macOS-latest + os_name: macOS python_version: '3.13' test: ${{ fromJson(needs.discover-standard-tests.outputs.test-files) }} @@ -219,7 +226,8 @@ jobs: discover-live-tests: needs: precheck - runs-on: self-hosted + runs-on: + group: Default timeout-minutes: 20 outputs: live-tests: ${{ steps.set-matrix.outputs.live-tests }} @@ -239,16 +247,20 @@ jobs: needs_docker=true fi - os_targets="self-hosted macOS-latest" + os_targets="linux macOS" if grep -q "@pytest.mark.docker_required" "$test_file" || grep -q "@pytest.mark.root_required" "$test_file" || grep -q 'skipif("darwin"' "$test_file"; then - os_targets="self-hosted" + os_targets="linux" elif grep -q 'require_tool("brew")' "$test_file" && ! grep -q 'require_tool("apt-get")' "$test_file" && ! grep -q "operations.apt.packages" "$test_file" && ! grep -q "ansible.builtin.apt" "$test_file"; then - os_targets="macOS-latest" + os_targets="macOS" fi for os_target in $os_targets; do - os_name=$(printf '%s' "$os_target" | tr '[:upper:]' '[:lower:]' | sed 's/-latest//') - entry="{\"name\":\"${test_name}-${os_name}\",\"path\":\"$test_file\",\"os\":\"$os_target\",\"needs_docker\":$needs_docker}" + if [ "$os_target" = "linux" ]; then + os_json='{"group":"Default"}' + else + os_json='"macOS-latest"' + fi + entry="{\"name\":\"${test_name}-${os_target}\",\"path\":\"$test_file\",\"os\":${os_json},\"os_name\":\"${os_target}\",\"needs_docker\":$needs_docker}" if [ "$first" = true ]; then first=false; else json_array+=","; fi json_array+="$entry" done From 1134af09ae295927b5552856113c5a437808c7b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:24:05 +0000 Subject: [PATCH 28/30] CI precheck: install Node 22 so pyright hook has a modern runtime ``uv run prek run --all-files`` runs the pyright hook, which executes pyright (a bundled Node.js app). On GH-hosted ``ubuntu-latest`` Node 22 is pre-installed, but on the self-hosted runner system Node is too old: pyright's ``vendor.js`` uses class-field syntax and throws ``SyntaxError: Unexpected token =`` against a Node ~10-era ``cjs/loader.js``. Pin a modern Node via ``actions/setup-node@v6`` in the precheck job so the hook has a runtime it can actually parse. --- .github/workflows/tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dfa60dbd..32b19d18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,6 +31,11 @@ jobs: with: python-version: '3.12' + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install uv uses: astral-sh/setup-uv@v8.0.0 with: From ab8907a4054d49a4a2e601012e4a8689128aaa8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:28:53 +0000 Subject: [PATCH 29/30] CI: pin XDG_CACHE_HOME=/runner/root/.cache on self-hosted runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted Linux runner's work dir lives on /runner, which is a separate filesystem from /root. uv's default cache lives under $HOME/.cache/uv (or $XDG_CACHE_HOME/uv if set) and uv hard-links from the cache into the venv — hard-links can't cross filesystem boundaries, so uv silently falls back to copying every dep, adding tens of seconds to every ``uv sync`` / ``uv venv``. Pin XDG_CACHE_HOME to a path on /runner (same fs as the work dir) so uv keeps its cache there and hard-linking succeeds. Wrapped in ``if: runner.environment == 'self-hosted'`` so GitHub-hosted runs (which don't have /runner and run as unprivileged ``runner`` user) aren't affected. Added to every job that runs uv: precheck, discover-standard-tests, build, discover-live-tests, live-integration, deploy-pages build, release-state. --- .github/workflows/deploy-pages.yml | 4 ++++ .github/workflows/release.yml | 4 ++++ .github/workflows/tests.yml | 28 +++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 8db35041..65bb8cd9 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -23,6 +23,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efddf092..479d2e48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,10 @@ jobs: fetch-depth: 0 ref: ${{ github.ref_name }} + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - uses: actions/setup-python@v6 with: python-version: "3.12" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 32b19d18..707084ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,12 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # On self-hosted runners, the runner work dir lives on /runner which + # is a separate filesystem from /root — keep uv's cache on the same + # fs as the work dir so hard-linking into venvs succeeds (uv falls + # back to copying across fs boundaries, which is much slower). The + # "if" guards below no-op this on GitHub-hosted runners where the + # path doesn't exist. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -26,6 +32,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Setup Python uses: actions/setup-python@v6 with: @@ -64,6 +74,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Discover standard test files id: set-matrix run: | @@ -111,11 +125,15 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Setup Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.target.python_version }} - + - name: Install uv uses: astral-sh/setup-uv@v8.0.0 with: @@ -240,6 +258,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Discover live integration test files id: set-matrix run: | @@ -290,6 +312,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Pin XDG_CACHE_HOME for self-hosted + if: runner.environment == 'self-hosted' + run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" + - name: Setup Python uses: actions/setup-python@v6 with: From 4aa46abb9fad25efe7f2e1ab3a45fe8109338aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 19:05:10 +0000 Subject: [PATCH 30/30] Revert "CI: pin XDG_CACHE_HOME=/runner/root/.cache on self-hosted runners" This reverts commit ab8907a4054d49a4a2e601012e4a8689128aaa8c. --- .github/workflows/deploy-pages.yml | 4 ---- .github/workflows/release.yml | 4 ---- .github/workflows/tests.yml | 28 +--------------------------- 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 65bb8cd9..8db35041 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -23,10 +23,6 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 479d2e48..efddf092 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,10 +28,6 @@ jobs: fetch-depth: 0 ref: ${{ github.ref_name }} - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - uses: actions/setup-python@v6 with: python-version: "3.12" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 707084ba..32b19d18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,12 +12,6 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - # On self-hosted runners, the runner work dir lives on /runner which - # is a separate filesystem from /root — keep uv's cache on the same - # fs as the work dir so hard-linking into venvs succeeds (uv falls - # back to copying across fs boundaries, which is much slower). The - # "if" guards below no-op this on GitHub-hosted runners where the - # path doesn't exist. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -32,10 +26,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Setup Python uses: actions/setup-python@v6 with: @@ -74,10 +64,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Discover standard test files id: set-matrix run: | @@ -125,15 +111,11 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Setup Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.target.python_version }} - + - name: Install uv uses: astral-sh/setup-uv@v8.0.0 with: @@ -258,10 +240,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Discover live integration test files id: set-matrix run: | @@ -312,10 +290,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Pin XDG_CACHE_HOME for self-hosted - if: runner.environment == 'self-hosted' - run: echo "XDG_CACHE_HOME=/runner/root/.cache" >> "$GITHUB_ENV" - - name: Setup Python uses: actions/setup-python@v6 with: