From 204835f26dcc7d7011e91ecc317014de91b5a61a Mon Sep 17 00:00:00 2001 From: Radiumgu Date: Sat, 5 Sep 2026 03:31:55 +0000 Subject: [PATCH] fix: verify the downloaded browser can resolve its libraries Playwright's host validation cannot be trusted to notice that a browser it just downloaded cannot launch, so `install-browser` reported success on a host missing 12 shared libraries, `browser_ok` reported true, the panel went green, and the failure only arrived at the user's first browse as an opaque stack trace. The cause is upstream and specific to linux-arm64: Playwright's registry declares the directory to scan as `chrome-linux`, while the arm64 build unpacks into `chrome-linux-arm64`. It scans a path that does not exist, finds no dependencies at all, concludes the host is fine and writes its DEPENDENCIES_VALIDATED marker -- which then suppresses re-validation for 30 days. MEASURED on Amazon Linux 2023 arm64: that marker was written 23 minutes BEFORE the libraries were installed. The same run on webkit, whose declared directory does match, threw and wrote no marker -- the two side by side are what identify the directory name as the cause. So this is a false negative, not a missing warning, and `host_deps_unsatisfied` -- which keys on Playwright's warning text -- can never fire for it. `missing_shared_libraries` therefore reads the real files: it enumerates the ELF objects actually on disk and asks ldd what cannot be resolved, never hardcoding a build's subdirectory name, since that assumption is the upstream bug. A successful download is followed by that probe, and a missing library becomes its own failed step rather than flipping the download's verdict -- the download genuinely succeeded, and labelling it failed would send the operator to re-fetch bytes already on disk when what they need is root and a package manager. Two guards keep the check from blocking installs that work. Libraries the build ships itself are excluded, both by passing the build's own directories as LD_LIBRARY_PATH and by name: without that, firefox reported libxul.so and nine others missing while launching perfectly. And ldd's `:` header line is not a soname -- unfiltered it was reported as a missing library named after an absolute path. "Could not determine" is distinct from "nothing missing" throughout: off Linux, with no ldd, or on timeout the probe answers None and the install proceeds. An absent probe is not evidence of a broken browser. Verified on the failing host: chromium and firefox both report no missing libraries and add no step, while webkit -- whose dependencies are not in the rpm package list -- reports 26 and fails the step with the remedy. --- src/kiro_crew/browser_cli/install.py | 74 +++++++++- src/kiro_crew/browser_cli/os_deps.py | 193 +++++++++++++++++++++++++++ test/test_browser_cli_install.py | 119 +++++++++++++++++ test/test_browser_cli_os_deps.py | 115 ++++++++++++++++ test/test_spawn_audit.py | 14 ++ 5 files changed, 511 insertions(+), 4 deletions(-) diff --git a/src/kiro_crew/browser_cli/install.py b/src/kiro_crew/browser_cli/install.py index 9068c1f9634..c1fcd65375c 100644 --- a/src/kiro_crew/browser_cli/install.py +++ b/src/kiro_crew/browser_cli/install.py @@ -702,6 +702,62 @@ def _step( } +def _engine_cache_dirs(engine: str) -> list[Path]: + """Cache directories holding builds for *engine*. + + Prefix-matched rather than composed from a revision, so the CHROMIUM entry + also picks up ``chromium_headless_shell-``. That is not a bonus: headless + is the default launch mode, so the headless shell is the binary a browse + actually starts, and a library check that looked only at ``chromium-`` + would clear the build that is not the one being run. + """ + cache = _browsers_cache_dir() + if cache is None: + return [] + try: + return [ + child + for child in cache.iterdir() + if child.is_dir() and child.name.startswith(engine) + ] + except OSError: + return [] + + +def _verify_browser_libraries(engine: str) -> dict[str, Any] | None: + """Step reporting that *engine*'s downloaded build cannot resolve its libraries. + + ``None`` when there is nothing to report -- no missing library, or the probe + could not run (see :func:`os_deps.missing_shared_libraries`). An unknown must + not fail an install: the browser download itself needs no privilege, and + turning "could not check" into "broken" would withdraw a working browser. + + A step of its own rather than flipping the download's verdict, because the + download genuinely SUCCEEDED. Labelling it failed would send the operator to + re-download bytes that are already on disk, when what they need is root and a + package manager. + """ + missing = os_deps.missing_shared_libraries(_engine_cache_dirs(engine)) + if not missing: + return None + listed = ", ".join(sorted(missing)) + hint = os_deps.missing_deps_hint() + detail = ( + f"{engine} downloaded, but {len(missing)} shared " + f"librar{'y' if len(missing) == 1 else 'ies'} cannot be resolved on this " + f"host, so the browser cannot launch: {listed}" + ) + return { + "name": f"verify-browser-libraries-{engine}", + "ok": False, + # Nothing exited non-zero -- the download really did succeed. Reported + # honestly rather than borrowing a failure code from a process that + # never ran. + "returncode": 0, + "stderr": f"{detail}\n\n{hint}" if hint else detail, + } + + def _download_browser(path: str, engine: str | None = None) -> list[dict[str, Any]]: """Download a browser build, adapting to what this host's OS allows. @@ -718,7 +774,10 @@ def _download_browser(path: str, engine: str | None = None) -> list[dict[str, An tried instead of only the last verdict. Every attempt is judged on its output as well as its exit code: a build whose - libraries are missing downloads "successfully" and cannot launch. + libraries are missing downloads "successfully" and cannot launch. That check + is not sufficient by itself either -- Playwright's validation false-negatives + on linux-arm64 -- so a successful download is FOLLOWED by a real library + probe (:func:`_verify_browser_libraries`). """ selected_engine = engine or _DEFAULT_BROWSER_ENGINE base = [path, "install-browser", selected_engine] @@ -736,13 +795,20 @@ def attempt(step_name: str, argv: list[str], with_hint: bool) -> dict[str, Any]: failure_signal=os_deps.host_deps_unsatisfied, ) + def with_library_check(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Append the library verdict when the download succeeded.""" + if not steps[-1]["ok"]: + return steps + verdict = _verify_browser_libraries(selected_engine) + return steps if verdict is None else [*steps, verdict] + if not os_deps.with_deps_supported(): - return [attempt(f"install-browser{suffix}", base, True)] + return with_library_check([attempt(f"install-browser{suffix}", base, True)]) first = attempt(f"install-browser{suffix}", base + ["--with-deps"], False) if first["ok"]: - return [first] - return [first, attempt(f"install-browser{suffix}-no-deps", base, True)] + return with_library_check([first]) + return with_library_check([first, attempt(f"install-browser{suffix}-no-deps", base, True)]) def install() -> dict[str, Any]: diff --git a/src/kiro_crew/browser_cli/os_deps.py b/src/kiro_crew/browser_cli/os_deps.py index 029c823ab89..5f04eec9faf 100644 --- a/src/kiro_crew/browser_cli/os_deps.py +++ b/src/kiro_crew/browser_cli/os_deps.py @@ -16,13 +16,23 @@ The read blocks (the os-release file), so a caller on the event loop offloads them -- the same contract as the rest of this package. + +This module also owns the check that the downloaded browser can actually RESOLVE +its libraries (:func:`missing_shared_libraries`), because Playwright's own host +validation cannot be relied on to say so -- on linux-arm64 it scans a directory +that does not exist and concludes the host is fine. See that function. """ from __future__ import annotations import logging +import os import platform +import subprocess +import time +from collections.abc import Iterable from functools import lru_cache +from pathlib import Path from kiro_crew import platform_compat @@ -234,6 +244,189 @@ def host_deps_unsatisfied(text: str) -> bool: Read the exit code AND this, never the exit code alone -- see :data:`_HOST_VALIDATION_MARKERS` for the measurement. + + NOT sufficient on its own either: Playwright's validation produces FALSE + NEGATIVES on linux-arm64, so a host with libraries missing can emit no + marker at all. :func:`missing_shared_libraries` is the check that does not + depend on Playwright noticing. """ lowered = (text or "").lower() return any(marker in lowered for marker in _HOST_VALIDATION_MARKERS) + + +#: Ceiling on the ELF files probed per browser directory. MEASURED: chromium +#: ships 14 (9 executables + 5 shared objects) and firefox 44, so the real +#: counts sit far below this -- it exists to bound a future layout, not to clip +#: today's. +_LDD_MAX_FILES = 200 +#: ``ldd`` on one file MEASURED at ~9 ms, so a whole browser directory is well +#: under a second. The budget covers the whole sweep, not one file. +_LDD_TIMEOUT_S = 60.0 + +#: ``ldd`` marks an unresolvable dependency with this. Matched on the line rather +#: than parsed: the left-hand side is the soname we want to report and the rest +#: of the line is formatting. +_LDD_NOT_FOUND = "not found" + + +def _library_search_dirs(directories: Iterable[Path]) -> list[Path]: + """Directories inside the build that hold its own shared objects. + + Fed to ``ldd`` as ``LD_LIBRARY_PATH`` because a browser ships libraries it + loads from beside itself, and ``ldd`` run with a bare environment cannot + resolve them. MEASURED: without this, firefox reports ``libxul.so``, + ``liblgpllibs.so``, ``libmozsqlite3.so`` and 7 more as missing while the + browser launches perfectly -- so the check would have blocked a WORKING + install, a worse failure than the one it exists to catch. Playwright passes + its own directory list for exactly this reason. + """ + dirs: set[Path] = set() + for directory in directories: + try: + for path in directory.rglob("*.so*"): + if path.is_file(): + dirs.add(path.parent) + except OSError: + continue + return sorted(dirs) + + +def _sonames_shipped_with_build(directories: Iterable[Path]) -> set[str]: + """File names of shared objects the build carries itself. + + A second, independent guard behind ``LD_LIBRARY_PATH``: a library present in + the tree is the build's own, so naming it "missing on this host" is wrong + regardless of whether ``ldd`` managed to resolve it. Keeps a loader quirk + (``$ORIGIN`` handling, a nested layout the search path missed) from + resurrecting the false positive above. + """ + names: set[str] = set() + for directory in directories: + try: + for path in directory.rglob("*.so*"): + if path.is_file(): + names.add(path.name) + except OSError: + continue + return names + + +def _elf_candidates(directory: Path) -> list[Path]: + """Executables and shared objects under *directory*, most-important first. + + Sorted so the main program is probed before its satellites when the ceiling + truncates: an executable outranks a ``.so``, and a shallower path outranks a + deeper one. Without an order the ceiling would drop an arbitrary subset, + which is how a check reports "no missing libraries" for a browser whose main + binary was never looked at. + """ + found: list[Path] = [] + for path in sorted(directory.rglob("*")): + try: + if not path.is_file() or path.is_symlink(): + continue + is_executable = os.access(path, os.X_OK) + is_shared_object = ".so" in path.name + if is_executable or is_shared_object: + found.append(path) + except OSError: + continue + found.sort(key=lambda p: (not os.access(p, os.X_OK), len(p.parts), str(p))) + return found[:_LDD_MAX_FILES] + + +def _missing_sonames_in_output(stdout: str, shipped: set[str]) -> set[str]: + """Unresolvable sonames in one ``ldd`` output, excluding the build's own. + + ``ldd`` prefixes its report with ``:`` when it has something to say + about the file itself; that header ends in a colon and is not a soname. Left + unfiltered it was reported as a missing library named after an absolute path + -- nonsense in the operator's remedy, and it named a file that is present. + """ + missing: set[str] = set() + for line in stdout.splitlines(): + stripped = line.strip() + if _LDD_NOT_FOUND not in stripped or stripped.endswith(":"): + continue + fields = stripped.split() + if not fields: + continue + soname = fields[0] + if soname.endswith(":") or "/" in soname or soname in shipped: + continue + missing.add(soname) + return missing + + +def missing_shared_libraries(directories: Iterable[Path]) -> set[str] | None: + """Sonames the downloaded browser needs that this host cannot resolve. + + Exists because Playwright's own host validation CANNOT be trusted to notice. + MEASURED on Amazon Linux 2023 arm64: its registry declares the directory to + scan as ``chrome-linux``, while the arm64 build unpacks into + ``chrome-linux-arm64``, so it scans a path that does not exist, finds no + dependencies at all, concludes the host is fine and writes its + ``DEPENDENCIES_VALIDATED`` marker -- which then suppresses re-validation for + 30 days. The marker was written 23 minutes BEFORE the libraries were + installed on that host. A false negative, not a missing warning: the + download reports success, ``browser_ok`` reports true, the panel goes green, + and the failure only arrives at the user's first browse as an opaque stack + trace. + + So this reads the real files, and never hardcodes a build's subdirectory + name -- that assumption is the upstream bug being worked around. + + Only libraries the HOST must provide are reported; the build's own bundled + ones are excluded twice over (see :func:`_library_search_dirs` and + :func:`_sonames_shipped_with_build`), because reporting those would block an + install that works. + + ``None`` means "cannot determine", NOT "nothing missing": off Linux (no + ``ldd``), when no directory exists, or when ``ldd`` itself is unavailable or + times out. Callers must not turn an unknown into a failed install -- an + absent probe is not evidence of a broken browser. + """ + if not platform_compat.IS_LINUX: + return None + dirs = [d for d in directories] + candidates: list[Path] = [] + for directory in dirs: + try: + if directory.is_dir(): + candidates.extend(_elf_candidates(directory)) + except OSError: + continue + if not candidates: + return None + shipped = _sonames_shipped_with_build(dirs) + env = dict(os.environ) + search = os.pathsep.join(str(d) for d in _library_search_dirs(dirs)) + if search: + existing = env.get("LD_LIBRARY_PATH", "") + env["LD_LIBRARY_PATH"] = f"{search}{os.pathsep}{existing}" if existing else search + missing: set[str] = set() + deadline = time.monotonic() + _LDD_TIMEOUT_S + probed = 0 + for path in candidates: + if time.monotonic() >= deadline: + logger.warning("ldd sweep timed out after %d files", probed) + break + try: + proc = subprocess.run( # noqa: S603 - fixed argv, path from our own cache + ["ldd", str(path)], + capture_output=True, + text=True, + timeout=max(1.0, deadline - time.monotonic()), + check=False, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + # A file ldd refuses (not an ELF, wrong class) is not evidence of a + # missing library. Skip it rather than letting it fail the host. + continue + probed += 1 + missing |= _missing_sonames_in_output(proc.stdout or "", shipped) + if not probed: + # Every probe failed -- most likely ldd is absent. Unknown, not clean. + return None + return missing diff --git a/test/test_browser_cli_install.py b/test/test_browser_cli_install.py index 61cab9e1983..0d05a843c71 100644 --- a/test/test_browser_cli_install.py +++ b/test/test_browser_cli_install.py @@ -306,6 +306,125 @@ def fake_run(argv: list[str], timeout: float) -> tuple[int, str, str]: assert ["/n/pw", "install-browser", "chromium"] in calls + +# --- the browser downloaded but cannot resolve its libraries ------------------- + + +def test_a_download_that_cannot_resolve_its_libraries_fails_the_install( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: Playwright's host validation FALSE-NEGATIVES on linux-arm64. + + Its registry declares the scan directory as ``chrome-linux`` while the arm64 + build unpacks into ``chrome-linux-arm64``, so it scans nothing, calls the + host good and writes ``DEPENDENCIES_VALIDATED`` -- MEASURED as written 23 + minutes before the libraries were actually installed. With no marker in the + output, ``host_deps_unsatisfied`` sees nothing, the download reports ok, and + the failure only lands at the user's first browse. So a real library probe + runs after a successful download. + """ + _wire(monkeypatch, {"npm": "/n/npm", "playwright-cli": "/n/pw"}) + monkeypatch.setattr( + mod.os_deps, "missing_shared_libraries", lambda dirs: {"libatk-1.0.so.0", "libgbm.so.1"} + ) + monkeypatch.setattr(mod.os_deps, "missing_deps_hint", lambda: "sudo dnf install -y atk") + + result = mod.install() + + assert result["ok"] is False + assert [s["name"] for s in result["steps"]] == [ + "npm-install-global", + "install-browser", + "verify-browser-libraries-chromium", + ] + # The download genuinely succeeded and is reported honestly -- sending the + # operator to re-download bytes already on disk is not the remedy. + assert result["steps"][1]["ok"] is True + verdict = result["steps"][2] + assert verdict["ok"] is False + assert verdict["returncode"] == 0 + assert "libatk-1.0.so.0" in verdict["stderr"] + assert "libgbm.so.1" in verdict["stderr"] + assert "sudo dnf install -y atk" in verdict["stderr"] + # The skills step never runs behind a browser that cannot launch. + assert not any(s["name"] == "install-skills" for s in result["steps"]) + + +def test_a_resolvable_download_adds_no_extra_step(monkeypatch: pytest.MonkeyPatch) -> None: + """The common case must stay a three-step install.""" + _wire(monkeypatch, {"npm": "/n/npm", "playwright-cli": "/n/pw"}) + monkeypatch.setattr(mod.os_deps, "missing_shared_libraries", lambda dirs: set()) + + result = mod.install() + + assert result["ok"] is True + assert [s["name"] for s in result["steps"]] == [ + "npm-install-global", + "install-browser", + "install-skills", + ] + + +def test_an_unprobeable_host_never_fails_the_install(monkeypatch: pytest.MonkeyPatch) -> None: + """"Could not check" must not become "broken". + + The download needs no privilege and may be perfectly good; withdrawing it + because ``ldd`` is absent would break hosts that work today. + """ + _wire(monkeypatch, {"npm": "/n/npm", "playwright-cli": "/n/pw"}) + monkeypatch.setattr(mod.os_deps, "missing_shared_libraries", lambda dirs: None) + + result = mod.install() + + assert result["ok"] is True + assert not any("verify-browser-libraries" in s["name"] for s in result["steps"]) + + +def test_the_library_check_covers_the_headless_shell_not_just_chromium( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Headless is the default launch mode, so the shell is what a browse starts. + + Checking only ``chromium-`` would clear the build that is not the one + being run. + """ + cache = tmp_path / "ms-playwright" + for name in ("chromium-1243", "chromium_headless_shell-1243", "firefox-1542"): + (cache / name).mkdir(parents=True) + monkeypatch.setattr(mod, "_browsers_cache_dir", lambda: cache) + + names = sorted(p.name for p in mod._engine_cache_dirs("chromium")) + + assert names == ["chromium-1243", "chromium_headless_shell-1243"] + + +def test_a_refused_with_deps_retry_is_still_library_checked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The recovery path must not skip the probe the direct path runs.""" + monkeypatch.setattr(mod.os_deps, "with_deps_supported", lambda: True) + monkeypatch.setattr(mod.os_deps, "missing_deps_hint", lambda: "") + monkeypatch.setattr(mod.os_deps, "missing_shared_libraries", lambda dirs: {"libgbm.so.1"}) + calls = _wire(monkeypatch, {"npm": "/n/npm", "playwright-cli": "/n/pw"}) + + def fake_run(argv: list[str], timeout: float) -> tuple[int, str, str]: + calls.append(list(argv)) + if "--with-deps" in argv: + return 1, "", "sudo: a password is required" + return 0, "", "" + + monkeypatch.setattr(mod, "_run", fake_run) + + result = mod.install() + + assert result["ok"] is False + assert [s["name"] for s in result["steps"]] == [ + "npm-install-global", + "install-browser", + "install-browser-no-deps", + "verify-browser-libraries-chromium", + ] + def test_a_zero_exit_carrying_the_host_validation_warning_is_a_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/test/test_browser_cli_os_deps.py b/test/test_browser_cli_os_deps.py index 21784b14dad..e76088cddf3 100644 --- a/test/test_browser_cli_os_deps.py +++ b/test/test_browser_cli_os_deps.py @@ -3,6 +3,7 @@ from __future__ import annotations import platform +import subprocess import pytest @@ -205,3 +206,117 @@ def test_ordinary_output_is_not_a_failure(self, text): def test_none_is_tolerated(self): assert mod.host_deps_unsatisfied(None) is False # type: ignore[arg-type] + + +class TestMissingSharedLibrariesDoesNotTrustPlaywright: + """The probe that exists because Playwright's own validation false-negatives. + + MEASURED on Amazon Linux 2023 arm64: Playwright's registry declares the scan + directory as ``chrome-linux`` while the arm64 build unpacks into + ``chrome-linux-arm64``, so it scans a non-existent path, finds nothing, calls + the host good and writes ``DEPENDENCIES_VALIDATED`` -- 23 minutes BEFORE the + libraries were installed. That marker then suppresses re-validation for 30 + days. So this probe reads real files and never hardcodes a build subdirectory. + """ + + @staticmethod + def _browser_dir(tmp_path, name: str = "chrome-linux-arm64"): + """A build laid out under a subdirectory Playwright's constant does NOT name.""" + d = tmp_path / "chromium-1243" / name + d.mkdir(parents=True) + binary = d / "chrome" + binary.write_bytes(b"\x7fELF fake") + binary.chmod(0o755) + return tmp_path / "chromium-1243" + + def test_missing_sonames_are_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path): + monkeypatch.setattr(platform_compat, "IS_LINUX", True) + browser = self._browser_dir(tmp_path) + + def fake_run(argv, **kwargs): + assert argv[0] == "ldd" + return subprocess.CompletedProcess( + argv, + 0, + stdout=( + "\tlinux-vdso.so.1 (0x0000ffff)\n" + "\tlibatk-1.0.so.0 => not found\n" + "\tlibgbm.so.1 => not found\n" + "\tlibc.so.6 => /lib64/libc.so.6 (0x0000ffff)\n" + ), + stderr="", + ) + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + + assert mod.missing_shared_libraries([browser]) == {"libatk-1.0.so.0", "libgbm.so.1"} + + def test_a_resolvable_build_reports_empty_not_none( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ): + """Empty and ``None`` are different answers and callers branch on both.""" + monkeypatch.setattr(platform_compat, "IS_LINUX", True) + browser = self._browser_dir(tmp_path) + monkeypatch.setattr( + mod.subprocess, + "run", + lambda argv, **kw: subprocess.CompletedProcess( + argv, 0, stdout="\tlibc.so.6 => /lib64/libc.so.6\n", stderr="" + ), + ) + + assert mod.missing_shared_libraries([browser]) == set() + + def test_an_unprobeable_host_answers_unknown_rather_than_clean( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ): + """No ``ldd`` on the host must not read as "no libraries missing". + + Returning an empty set here would let a broken browser through as + verified -- the exact false-negative shape this probe replaces. + """ + monkeypatch.setattr(platform_compat, "IS_LINUX", True) + browser = self._browser_dir(tmp_path) + + def no_ldd(argv, **kwargs): + raise FileNotFoundError(2, "No such file or directory: 'ldd'") + + monkeypatch.setattr(mod.subprocess, "run", no_ldd) + + assert mod.missing_shared_libraries([browser]) is None + + def test_off_linux_is_unknown(self, monkeypatch: pytest.MonkeyPatch, tmp_path): + monkeypatch.setattr(platform_compat, "IS_LINUX", False) + assert mod.missing_shared_libraries([self._browser_dir(tmp_path)]) is None + + def test_an_absent_directory_is_unknown(self, monkeypatch: pytest.MonkeyPatch, tmp_path): + monkeypatch.setattr(platform_compat, "IS_LINUX", True) + assert mod.missing_shared_libraries([tmp_path / "never-downloaded"]) is None + + def test_the_main_executable_is_probed_before_satellites( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ): + """Ordering is load-bearing when the file ceiling truncates. + + Without it the ceiling drops an arbitrary subset, which is how a check + reports a clean host for a browser whose main binary was never examined. + """ + monkeypatch.setattr(platform_compat, "IS_LINUX", True) + root = tmp_path / "chromium-1243" + deep = root / "chrome-linux-arm64" / "swiftshader" + deep.mkdir(parents=True) + (deep / "libGLESv2.so").write_bytes(b"\x7fELF") + main = root / "chrome-linux-arm64" / "chrome" + main.write_bytes(b"\x7fELF") + main.chmod(0o755) + + probed: list[str] = [] + + def record(argv, **kwargs): + probed.append(argv[1]) + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(mod.subprocess, "run", record) + mod.missing_shared_libraries([root]) + + assert probed[0] == str(main) diff --git a/test/test_spawn_audit.py b/test/test_spawn_audit.py index 32e5dde4daa..c23f5daf6c2 100644 --- a/test/test_spawn_audit.py +++ b/test/test_spawn_audit.py @@ -204,6 +204,20 @@ def _is_bundled_skill_asset(path: Path) -> bool: BENIGN_SPAWNS: frozenset[str] = frozenset( { "acp/runtime.py::_get_rss_mb", + # The browser library probe. Fixed two-element argv `["ldd", ]`, no + # shell, no cwd. The path is never agent-influenced: it is enumerated by + # rglob under Playwright's own browser cache (a directory whose contents + # this product downloads from Playwright's CDN), so an agent cannot name + # the file probed, and a hostile FILE NAME cannot become a command + # because argv is a list. It reads only the ELF headers of files already + # on disk and mutates nothing. Routing it through sandboxed_spawn_argv + # would defeat its purpose: the probe exists to answer whether this HOST + # can resolve the browser's libraries, and a sandbox with a scrubbed + # environment would answer for the sandbox instead -- LD_LIBRARY_PATH is + # the one input that must survive to the child (see + # os_deps._library_search_dirs, without which a working firefox reports + # ten missing libraries). + "browser_cli/os_deps.py::missing_shared_libraries", # The shadow-venv update engine's four spawns. None is agent-influenced # and none can route through sandboxed_spawn_argv, because the engine's # whole job is to build the NEXT gateway install outside the agent