diff --git a/Dockerfile b/Dockerfile index e0d62381d5..e2a8eb77b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,7 @@ FROM debian:13.4 ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 -# The pm-pinned Chromium pair lives in the managed tool store at +# The pm-pinned full Chromium lives in the managed tool store at # /opt/hermes/tools — outside the /opt/data volume mount, so the # build-time install survives the volume overlay at runtime. pm's # chromium package fact exports the same value (PLAYWRIGHT_BROWSERS_PATH @@ -183,14 +183,15 @@ WORKDIR /opt/hermes # drifted to 0.11.6 while pm/lock.json pinned uv 0.12.3. That is exactly # the two-authorities failure the pm design exists to end. The image is now # a pin consumer: the stdlib-only pm provisioner reads pm/lock.json and -# stages the pinned uv + the pinned Chromium pair (sha256-verified at +# stages the pinned uv + full Chromium (sha256-verified at # download — the same code path pm.sh/pm.ps1 and the desktop payload use) # into the image's own runtime dir, a self-contained store baked under # /opt/hermes, outside the /opt/data volume so it survives the overlay. # The pinned uv is linked onto PATH so the `uv sync` / `uv pip install` # build steps below run the lockfile's uv, not a second download. # -# The Chromium pair is staged here rather than by `npx playwright install`, +# Full Chromium supports both headed and headless sessions. It is staged +# here rather than by `npx playwright install`, # which fetched whatever revision the npm-resolved playwright wanted, # unverified, and recorded no fact. The resolved browser binary path is # baked to /etc/hermes/agent-browser-executable-path for stage2-hook.sh: @@ -207,7 +208,7 @@ COPY hermes_constants.py hermes_constants.py # PM imports the shared stdlib runtime path and locking owners before deps exist. COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/ RUN set -eu; \ - python3 -m pm.cli install uv chromium chromium-headless-shell; \ + python3 -m pm.cli install uv chromium; \ ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \ python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \ uv --version; \ diff --git a/apps/desktop/scripts/audit-bundle-arch.mjs b/apps/desktop/scripts/audit-bundle-arch.mjs index 0078bf051a..9aaeaabb2c 100644 --- a/apps/desktop/scripts/audit-bundle-arch.mjs +++ b/apps/desktop/scripts/audit-bundle-arch.mjs @@ -203,15 +203,11 @@ const EXEMPT_PATTERNS = [ // payload stages the x64 exe, which Windows runs under built-in // emulation (its own postinstall falls back to x64 on arm64). /agent-payload[/\\]tools[/\\]agent-browser-[^/\\]+[/\\]bin[/\\]agent-browser-win32-x64\.exe$/i, - // chromium / chromium-headless-shell are pm packages too: CfT publishes - // no native win-arm64 build, so on win32-arm64 the payload stages the - // win64 (x64) build and Windows runs it under emulation — the same - // choice the package code makes (PlaywrightBrowser._CFT maps win32-arm64 - // to win64). Store entries are named by playwright revision only - // (chromium-, chromium_headless_shell-, dashes→underscores); - // the zips extract in place, so the x64 trees carry a -win64 segment. - // Scoping to that segment keeps linux/darwin chromium audited. - /agent-payload[/\\]tools[/\\]chromium(_headless_shell)?-[^/\\]+[/\\](chrome|chrome-headless-shell)-win64[/\\]/i, + // CfT publishes no native win-arm64 Chromium, so PM stages the win64 + // (x64) build and Windows runs it under emulation. The zip extracts + // into chrome-win64 inside the chromium- store entry. Scoping to + // that segment keeps linux/darwin Chromium audited. + /agent-payload[/\\]tools[/\\]chromium-[^/\\]+[/\\]chrome-win64[/\\]/i, // The uv wheel/build cache (uv-cache/) is DELIBERATELY shipped with the // payload for warm rebuilds of the mutable venv (pm bundle copies it). // It holds cached sdists/archives that uv may have built for ANY arch diff --git a/apps/desktop/scripts/audit-bundle-arch.test.mjs b/apps/desktop/scripts/audit-bundle-arch.test.mjs index 4dc673f726..ebcf8fdd87 100644 --- a/apps/desktop/scripts/audit-bundle-arch.test.mjs +++ b/apps/desktop/scripts/audit-bundle-arch.test.mjs @@ -158,21 +158,24 @@ test('the emulated x64 agent-browser exe is exempt in a win32-arm64 payload', () assert.equal(isExemptPath('resources/agent-payload/tools/something-1.0-win32-arm64/bin/thing.exe'), false) }) -test('the emulated x64 chromium trees are exempt in a win32-arm64 payload', () => { - // PlaywrightBrowser._CFT maps win32-arm64 to the CfT win64 (x64) build; - // store entries are named by playwright revision (dashes→underscores) - // and the zip extracts in place, so the x64 trees sit under - // chrome-win64 / chrome-headless-shell-win64 inside the entry. +test('only the emulated full Chromium win64 tree is exempt', () => { + // CfT has no native win-arm64 build. The x64 zip extracts into + // chrome-win64 inside the revision-named Chromium store entry. for (const relPath of [ 'resources/agent-payload/tools/chromium-1208/chrome-win64/chrome.exe', - 'resources\\agent-payload\\tools\\chromium-1208\\chrome-win64\\chrome.dll', - 'resources/agent-payload/tools/chromium_headless_shell-1208/chrome-headless-shell-win64/chrome-headless-shell.exe' + 'resources\\agent-payload\\tools\\chromium-1208\\chrome-win64\\chrome.dll' ]) { assert.equal(isExemptPath(relPath), true, relPath) } - // The linux/darwin chromium trees stay audited: no -win64 segment. - assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-linux/chrome'), false) - assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-mac-arm64/chrome'), false) + // Other platforms and obsolete shell payloads have no exemption. + for (const relPath of [ + 'resources/agent-payload/tools/chromium-1208/chrome-linux/chrome', + 'resources/agent-payload/tools/chromium-1208/chrome-mac-arm64/chrome', + 'resources/agent-payload/tools/chromium_headless_shell-1208/chrome-headless-shell-win64/chrome-headless-shell.exe', + 'resources/agent-payload/tools/chromium-1208/chrome-headless-shell-win64/chrome-headless-shell.exe' + ]) { + assert.equal(isExemptPath(relPath), false, relPath) + } }) test('the uv wheel/build cache is exempt in the payload', () => { diff --git a/apps/desktop/scripts/sign-nested-chromium.mjs b/apps/desktop/scripts/sign-nested-chromium.mjs index 6fc4850a69..474e18839d 100644 --- a/apps/desktop/scripts/sign-nested-chromium.mjs +++ b/apps/desktop/scripts/sign-nested-chromium.mjs @@ -6,7 +6,7 @@ // treats Versions/A/Foo as a bundle and dies with "code object is not // signed at all" (or "bundle format is ambiguous" on the Foo.framework/Foo // symlink). Sign the enclosing .app with --deep. Loose Mach-O outside -// any .app (headless-shell + its dylibs) is signed as a file. +// any .app (such as a loose dylib) is signed as a file. // // signIgnore still keeps osx-sign off the chromium trees. @@ -37,7 +37,7 @@ export function chromiumRoots(payload) { if (!fs.existsSync(tools)) return [] return fs .readdirSync(tools, { withFileTypes: true }) - .filter(ent => ent.isDirectory() && /^chromium(_headless_shell)?-\d+/.test(ent.name)) + .filter(ent => ent.isDirectory() && /^chromium-\d+/.test(ent.name)) .map(ent => path.join(tools, ent.name)) } diff --git a/apps/desktop/scripts/sign-nested-chromium.test.mjs b/apps/desktop/scripts/sign-nested-chromium.test.mjs index 034d69fdf4..a6d5dfb43b 100644 --- a/apps/desktop/scripts/sign-nested-chromium.test.mjs +++ b/apps/desktop/scripts/sign-nested-chromium.test.mjs @@ -39,14 +39,14 @@ test('isMachO accepts a 64-bit Mach-O magic and rejects text', () => { } }) -test('chromiumRoots only names chromium store entries', () => { +test('chromiumRoots only names full Chromium store entries', () => { const payload = tempRoot() try { fs.mkdirSync(path.join(payload, 'tools', 'chromium-1208'), { recursive: true }) fs.mkdirSync(path.join(payload, 'tools', 'chromium_headless_shell-1208'), { recursive: true }) fs.mkdirSync(path.join(payload, 'tools', 'uv-0.12.3-darwin-arm64'), { recursive: true }) const roots = chromiumRoots(payload).map(p => path.basename(p)).sort() - assert.deepEqual(roots, ['chromium-1208', 'chromium_headless_shell-1208']) + assert.deepEqual(roots, ['chromium-1208']) } finally { fs.rmSync(payload, { recursive: true, force: true }) } @@ -58,7 +58,7 @@ test('listTopLevelApps finds .app dirs and listLooseMachO skips them', () => { const app = path.join(root, 'Google Chrome for Testing.app') fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true }) fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf()) - const loose = path.join(root, 'chrome-headless-shell') + const loose = path.join(root, 'libEGL.dylib') fs.writeFileSync(loose, machoBuf()) assert.deepEqual(listTopLevelApps(root), [app]) assert.deepEqual(listLooseMachO(root), [loose]) @@ -118,9 +118,7 @@ test('signNestedChromium --deep signs the .app and file-signs loose Mach-O', () const app = path.join(payload, 'tools', 'chromium-1208', 'Google Chrome for Testing.app') fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true }) fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf()) - const looseDir = path.join(payload, 'tools', 'chromium_headless_shell-1208') - fs.mkdirSync(looseDir, { recursive: true }) - const loose = path.join(looseDir, 'chrome-headless-shell') + const loose = path.join(payload, 'tools', 'chromium-1208', 'libEGL.dylib') fs.writeFileSync(loose, machoBuf()) const calls = [] const r = signNestedChromium(payload, { diff --git a/apps/desktop/scripts/tasks-scroll.test.mjs b/apps/desktop/scripts/tasks-scroll.test.mjs index 82275ea875..5c5a42dcbd 100644 --- a/apps/desktop/scripts/tasks-scroll.test.mjs +++ b/apps/desktop/scripts/tasks-scroll.test.mjs @@ -29,7 +29,7 @@ before(async () => { }) await server.listen() url = 'http://127.0.0.1:18120/scripts/fixtures/tasks-scroll.html' - browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }) + browser = await chromium.launch({ channel: 'chromium', executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, headless: true, args: ['--no-sandbox'] }) }) after(async () => { await browser?.close() diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 486879e7b0..428082650c 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -638,7 +638,7 @@ if [ -d "$INSTALL_DIR/skills" ]; then fi # --- Point agent-browser at the pinned Chromium binary --- -# The image's Dockerfile pm-provisions the pinned Chromium pair into +# The image's Dockerfile pm-provisions pinned full Chromium into # $HERMES_RUNTIME_DIR (/opt/hermes/tools) at BUILD time and bakes the # resolved browser binary path into /etc/hermes/agent-browser-executable-path # (the layout differs per arch — chrome-linux64/chrome on amd64, diff --git a/docs/shared-bundle-builds.md b/docs/shared-bundle-builds.md index d959b9aa9b..7cec3f69f4 100644 --- a/docs/shared-bundle-builds.md +++ b/docs/shared-bundle-builds.md @@ -79,6 +79,12 @@ Docker has its own curated extras and image lifecycle. Nix uses uv2nix and separate derivations. [Stable release admission](stable-releases.md) coordinates their acceptance and publication with desktop and Termux packages. +PM and Docker ship full Chromium, without a separate headless shell. The same +executable serves headed and headless sessions. Browser launchers use the +selected PM executable; direct Playwright callers select the `chromium` channel. +Native staging removes retired package facts and directories from its build +cache. It does not remove browser files from the user's machine-wide store. + ## Verification boundary Tests execute shared snapshot/manifest helpers on real git fixtures, stage diff --git a/evals/desktop_bug_campaign/async-report-live.mjs b/evals/desktop_bug_campaign/async-report-live.mjs index 3e64ecbe7c..25e6bd6fdb 100644 --- a/evals/desktop_bug_campaign/async-report-live.mjs +++ b/evals/desktop_bug_campaign/async-report-live.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process' const a=process.env.ASYNC_REPORT_ARTIFACT_DIR if (!a) throw new Error('Set ASYNC_REPORT_ARTIFACT_DIR to an offline artifact directory') const tag=process.argv[2]??'after' -const browser=await chromium.launch({headless:true,args:['--no-sandbox']}) +const browser=await chromium.launch({channel:'chromium',executablePath:process.env.AGENT_BROWSER_EXECUTABLE_PATH,headless:true,args:['--no-sandbox']}) const page=await browser.newPage({viewport:{width:1400,height:1000}}) const errors=[];page.on('pageerror',e=>errors.push(String(e))) const producer=JSON.parse(fs.readFileSync(`${a}/producer.json`,'utf8')) diff --git a/evals/desktop_bug_campaign/navigation-markdown-live.mjs b/evals/desktop_bug_campaign/navigation-markdown-live.mjs index a71abe9382..f3761257b0 100644 --- a/evals/desktop_bug_campaign/navigation-markdown-live.mjs +++ b/evals/desktop_bug_campaign/navigation-markdown-live.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs' import { execFileSync } from 'node:child_process' const artifact = process.env.NAVIGATION_ARTIFACT_DIR ?? '/home/teknium/.hermes/cache/desktop-bugs-74848ed3/navigation-markdown' const tag = process.argv[2] ?? 'after' -const browser = await chromium.launch({headless: true, args: ['--no-sandbox']}) +const browser = await chromium.launch({channel: 'chromium', executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, headless: true, args: ['--no-sandbox']}) const context = await browser.newContext({permissions: ['clipboard-read', 'clipboard-write']}) const page = await context.newPage() const errors = [] diff --git a/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs b/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs index 1cea52e0ca..4618e8b651 100644 --- a/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs +++ b/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import assert from 'node:assert/strict'; const out=process.env.THREAD_SCROLL_OUTPUT; assert.ok(out); -const browser=await chromium.launch({headless:true,args:['--no-sandbox']}); +const browser=await chromium.launch({channel:'chromium',executablePath:process.env.AGENT_BROWSER_EXECUTABLE_PATH,headless:true,args:['--no-sandbox']}); const result={}; try { const page=await browser.newPage({viewport:{width:1200,height:800}}); diff --git a/evals/desktop_bug_campaign/thread-scroll/probe.mjs b/evals/desktop_bug_campaign/thread-scroll/probe.mjs index 48f618c6fd..87f2846b4f 100644 --- a/evals/desktop_bug_campaign/thread-scroll/probe.mjs +++ b/evals/desktop_bug_campaign/thread-scroll/probe.mjs @@ -5,7 +5,7 @@ const out=process.env.THREAD_SCROLL_OUTPUT; assert.ok(out, 'Set THREAD_SCROLL_OUTPUT to the isolated artifact directory'); fs.mkdirSync(out,{recursive:true}); const url=process.env.THREAD_SCROLL_URL ?? 'http://127.0.0.1:18480/scroll-campaign-probe.html?thread'; -const browser=await chromium.launch({headless:true,args:['--no-sandbox']}); +const browser=await chromium.launch({channel:'chromium',executablePath:process.env.AGENT_BROWSER_EXECUTABLE_PATH,headless:true,args:['--no-sandbox']}); try { const page=await browser.newPage({viewport:{width:1200,height:800}}); const errors=[];page.on('pageerror',e=>errors.push(e.message)); diff --git a/evals/desktop_mcp_oauth/renderer_lifecycle.mjs b/evals/desktop_mcp_oauth/renderer_lifecycle.mjs index 4f40f969f4..02144f506e 100644 --- a/evals/desktop_mcp_oauth/renderer_lifecycle.mjs +++ b/evals/desktop_mcp_oauth/renderer_lifecycle.mjs @@ -90,6 +90,8 @@ const server = http.createServer(async (req, res) => { }) await new Promise(r => server.listen(0, '127.0.0.1', r)) const browser = await chromium.launch({ + channel: 'chromium', + executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, headless: true, args: ['--no-sandbox'], ...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : {}) diff --git a/hermes_cli/browser_runtime.py b/hermes_cli/browser_runtime.py new file mode 100644 index 0000000000..7ff5f3e703 --- /dev/null +++ b/hermes_cli/browser_runtime.py @@ -0,0 +1,16 @@ +"""Read-only full-Chromium selection shared by browser launchers.""" + +from __future__ import annotations + +import os + +import pm + + +def chromium_executable() -> str | None: + """Prefer an explicit override, then PM; None leaves resolution to Playwright.""" + override = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH") + if override: + return override + installed = pm.installed_package("chromium") + return str(installed.binary) if installed and installed.binary is not None else None diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py index 66b8098373..1a391d5dee 100644 --- a/hermes_cli/doctor_live.py +++ b/hermes_cli/doctor_live.py @@ -10,6 +10,7 @@ import os from dataclasses import dataclass from typing import Callable, List, Optional +from hermes_cli.browser_runtime import chromium_executable from hermes_cli.doctor import _section, check_info from hermes_cli.doctor_report import check_fail, check_ok, check_warn @@ -89,7 +90,10 @@ def _launch_browser_probe(timeout: float) -> tuple: except ImportError: return (False, "playwright not installed") with sync_playwright() as p: - browser = p.chromium.launch(headless=True, timeout=timeout * 1000) + browser = p.chromium.launch( + channel="chromium", executable_path=chromium_executable(), + headless=True, timeout=timeout * 1000, + ) try: browser.new_page().goto("about:blank", timeout=timeout * 1000) finally: diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index 0c318b0b06..cd34c7e33a 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -364,7 +364,6 @@ def _check_chromium() -> None: Lazy import: browser_tool is ~150KB; an import failure is a separate bug surfaced elsewhere. Camofox, a CDP override, a cloud provider, or Lightpanda all bypass the local Chromium requirement (no warning). """ - from hermes_cli.doctor import PROJECT_ROOT try: from tools.browser_tool import _is_camofox_mode from tools.browser_tool_cloud import _get_cloud_provider @@ -377,8 +376,7 @@ def _check_chromium() -> None: return if not check_bool(_chromium_installed(), ("Playwright Chromium", "(browser engine)"), ("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)")): - with_deps = "" if sys.platform == "win32" else "--with-deps " - check_info(f"Install with: cd {PROJECT_ROOT} && npx playwright install {with_deps}chromium") + check_info("Install with: hermes pm install chromium") def _check_lightpanda() -> None: diff --git a/hermes_cli/setup_summary.py b/hermes_cli/setup_summary.py index 9ca7a0b82e..cd95157d75 100644 --- a/hermes_cli/setup_summary.py +++ b/hermes_cli/setup_summary.py @@ -28,7 +28,7 @@ _BROWSER_MISSING_HINTS = { "Browserbase": "npm install -g agent-browser and set BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID", "Browser Use": "npm install -g agent-browser and set BROWSER_USE_API_KEY", "Camofox": "CAMOFOX_URL", - "Local browser": "npm install -g agent-browser && agent-browser install --with-deps"} + "Local browser": "hermes pm install agent-browser"} _BROWSER_MISSING_DEFAULT = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browser Use or Browserbase" _WEB_MISSING = ("EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, " "PERPLEXITY_API_KEY, KEENABLE_API_KEY, or SEARXNG_URL") diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py index 5fdfcfee95..7e3dbaf694 100644 --- a/hermes_cli/tools_config_post_setup.py +++ b/hermes_cli/tools_config_post_setup.py @@ -67,25 +67,48 @@ def _post_setup_lightpanda() -> None: _print_info(" Lightpanda has no native Windows build; run Hermes under WSL2.") -def _install_chromium(install_cmd: list[str]) -> None: - """Run the agent-browser Chromium install command and report the outcome.""" - _print_info(" Installing Chromium (~170MB one-time download)...") +def _install_chromium() -> None: + """Install the managed full Chromium package, without a second headless shell.""" + _print_info(" Installing pinned Chromium...") try: - result = _run_text(install_cmd, cwd=str(PROJECT_ROOT), timeout=600, creationflags=_post_setup_no_window_flags()) - if result.returncode == 0: - _print_success(" Chromium installed") - # Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install. - import tools.browser_tool as _bt - _bt._cached_chromium_installed = None - return - _print_warning(" Chromium install failed:") - for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: - _print_info(f" {line[:200]}") - except subprocess.TimeoutExpired: - _print_warning(" Chromium install timed out (>10min)") + import pm + pm.ensure("chromium", explicit=True) + _print_success(" Chromium installed") + # Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install. + import tools.browser_tool as _bt + _bt._cached_chromium_installed = None except Exception as exc: _print_warning(f" Chromium install failed: {exc}") - _print_info(" Run manually: npx agent-browser install --with-deps") + _print_info(" Run manually: hermes pm install chromium") + return + + # Preserve --with-deps on apt-based Linux without invoking a browser downloader. + if sys.platform != "linux" or not shutil.which("apt-get"): + return + try: + from tools.browser_tool_install import _resolve_npx_bin + npx_bin = _resolve_npx_bin() + if not npx_bin: + _print_warning(" npx not found - Chromium system dependencies were not installed") + else: + from tools.browser_tool import _build_browser_env + + env = _build_browser_env() + env["PATH"] = f"{Path(npx_bin).parent}{os.pathsep}{env.get('PATH', '')}" + _print_info(" Installing Chromium system dependencies...") + result = _run_text( + [npx_bin, "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium"], + cwd=str(PROJECT_ROOT), timeout=600, + env=env, + creationflags=_post_setup_no_window_flags()) + if result.returncode == 0: + return + _print_warning(" Chromium system dependency install failed:") + for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: + _print_info(f" {line[:200]}") + except Exception as exc: + _print_warning(f" Chromium system dependency install failed: {exc}") + _print_info(" Run manually: npx playwright install-deps chromium") def _post_setup_agent_browser(post_setup_key: str) -> None: @@ -108,10 +131,8 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: # agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx # (or a global/Hermes-managed install) instead of a local `npm install`, so there's no node_modules/ # population step here anymore. - from tools.browser_tool import AGENT_BROWSER_NPX_SPEC from tools.browser_tool_install import ( - _chromium_installed, _running_in_docker, _find_agent_browser, _resolve_npx_bin, - _is_npx_agent_browser_sentinel) + _chromium_installed, _running_in_docker, _find_agent_browser) except Exception as exc: # pragma: no cover — defensive _print_warning(f" Could not check Chromium status: {exc}") return @@ -119,7 +140,7 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: # Reuse the runtime resolution cascade (PATH -> Homebrew/Hermes-managed node -> npx) rather than # a bare shutil.which — Hermes-managed-Node-only setups resolve agent-browser/npx only that way. try: - browser_cmd = _find_agent_browser(validate=False) + _find_agent_browser(validate=False) except FileNotFoundError: _print_warning(" npx not found - browser tools require Node.js: https://nodejs.org") return @@ -140,17 +161,7 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: " docker pull ghcr.io/nousresearch/hermes-agent:latest") return - if _is_npx_agent_browser_sentinel(browser_cmd): - # Re-resolve npx via the same cascade _find_agent_browser used — a bare shutil.which("npx") - # would silently diverge and hand subprocess.run a None argument. - npx_bin = _resolve_npx_bin() - if not npx_bin: - _print_warning(" npx not found - install Chromium manually: npx agent-browser install --with-deps") - return - install_cmd = [npx_bin, "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", "--with-deps"] - else: - install_cmd = [browser_cmd, "install", "--with-deps"] - _install_chromium(install_cmd) + _install_chromium() def _post_setup_camofox() -> None: diff --git a/optional-skills/web-development/har-derived-api-client/SKILL.md b/optional-skills/web-development/har-derived-api-client/SKILL.md index 3bd98c74da..5ed19b28d3 100644 --- a/optional-skills/web-development/har-derived-api-client/SKILL.md +++ b/optional-skills/web-development/har-derived-api-client/SKILL.md @@ -41,7 +41,7 @@ HAR recording works differently in each case (see How to Run). ## Prerequisites - Playwright + a browser binary (capture step only): - - `pip install playwright` then `playwright install chromium` + - `pip install playwright` then `playwright install chromium --no-shell` - (If a system Playwright already has browsers under `~/.cache/ms-playwright`, reuse it.) - `requests` or `httpx` for the replay step (stdlib `urllib` also works). - No API keys. Any keys/tokens the client needs are the ones the HAR captured. diff --git a/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py b/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py index 3301bbc8c0..445a01ecb1 100644 --- a/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py +++ b/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py @@ -48,7 +48,7 @@ def main() -> int: args = ap.parse_args() with sync_playwright() as p: - browser = p.chromium.launch(headless=not args.headed) + browser = p.chromium.launch(channel="chromium", headless=not args.headed) context = browser.new_context( record_har_path=args.har_path, record_har_content="embed", # keep response bodies in the HAR diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index 7576cab456..5e09af18f4 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -20,6 +20,7 @@ import sys from pathlib import Path from typing import Optional +from hermes_cli.browser_runtime import chromium_executable from hermes_constants import get_hermes_home from plugins.google_meet import process_manager as pm @@ -105,9 +106,9 @@ def _cmd_setup() -> int: try: from playwright.sync_api import sync_playwright with sync_playwright() as p: - exe = p.chromium.executable_path + exe = chromium_executable() or p.chromium.executable_path chromium_ok = bool(exe and Path(exe).exists()) - chromium_msg = f"ok ({exe})" if chromium_ok else "not installed — run: python -m playwright install chromium" + chromium_msg = f"ok ({exe})" if chromium_ok else "not installed — run: python -m playwright install chromium --no-shell" except Exception as e: chromium_msg = f"probe failed: {e}" print(f" chromium : {chromium_msg}") @@ -153,9 +154,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: except Exception as e: print(f" pip install failed: {e}") return 1 - print("\n[2/3] python -m playwright install chromium") + print("\n[2/3] python -m playwright install chromium --no-shell") try: - if subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False, + if subprocess.run([sys.executable, "-m", "playwright", "install", "chromium", "--no-shell"], check=False, stdin=subprocess.DEVNULL).returncode != 0: print(" playwright install failed (may already be installed)") except Exception as e: @@ -201,7 +202,7 @@ def _cmd_auth() -> int: from playwright.sync_api import sync_playwright except ImportError: print("playwright is not installed. run:\n" - " pip install playwright && python -m playwright install chromium") + " pip install playwright && python -m playwright install chromium --no-shell") return 1 path = _auth_state_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -209,7 +210,9 @@ def _cmd_auth() -> int: f"saving storage state to: {path}") try: with sync_playwright() as pw: - browser = pw.chromium.launch(headless=False) + browser = pw.chromium.launch( + channel="chromium", executable_path=chromium_executable(), headless=False, + ) context = browser.new_context() context.new_page().goto("https://accounts.google.com/", wait_until="domcontentloaded") with contextlib.suppress(EOFError): diff --git a/plugins/google_meet/meet_bot.py b/plugins/google_meet/meet_bot.py index eddc2cc020..c6f19a4bf7 100644 --- a/plugins/google_meet/meet_bot.py +++ b/plugins/google_meet/meet_bot.py @@ -22,6 +22,7 @@ from pathlib import Path from types import SimpleNamespace from typing import Optional +from hermes_cli.browser_runtime import chromium_executable from plugins.google_meet._jsonfile import write_json_atomic # Short three-segment code, a lookup URL, or /new. Anything else is rejected. @@ -409,7 +410,7 @@ def run_bot() -> int: except ImportError as e: state.set(error=f"playwright not installed: {e}", exited=True) sys.stderr.write("google_meet bot: playwright is not installed. Run " - "`pip install playwright && python -m playwright install chromium`\n") + "`pip install playwright && python -m playwright install chromium --no-shell`\n") if rt["bridge"]: rt["bridge"].teardown() return 3 @@ -424,7 +425,10 @@ def run_bot() -> int: context_args["storage_state"] = cfg.auth_state try: with sync_playwright() as pw: - browser = pw.chromium.launch(headless=not cfg.headed, args=chrome_args) + browser = pw.chromium.launch( + channel="chromium", executable_path=chromium_executable(), + headless=not cfg.headed, args=chrome_args, + ) context = browser.new_context(**context_args) page = context.new_page() try: diff --git a/plugins/google_meet/tools.py b/plugins/google_meet/tools.py index 1ac0afa1d0..9eaeb0b957 100644 --- a/plugins/google_meet/tools.py +++ b/plugins/google_meet/tools.py @@ -146,7 +146,7 @@ def handle_meet_join(args: Dict[str, Any], **_kw) -> str: return {"ok": False, "error": ( "google_meet plugin prerequisites missing — install with " "`pip install playwright && python -m playwright install " - "chromium`. Plugin is supported on Linux and macOS only.")} + "chromium --no-shell`. Plugin is supported on Linux and macOS only.")} return pm.start(**common) return _dispatch(args.get("node"), "start_bot", lambda c: c.start_bot(**common), _local) diff --git a/pm/lock.json b/pm/lock.json index 870c84debc..df472ac1f3 100644 --- a/pm/lock.json +++ b/pm/lock.json @@ -38,35 +38,6 @@ }, "version": "1208+145.0.7632.6" }, - "chromium-headless-shell": { - "artifacts": { - "darwin-arm64": { - "sha256": "8510b9b1575538aa6a092ed16d981738b2ebf52c5e41ea4c54a7ce16756cdcf5", - "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-arm64/chrome-headless-shell-mac-arm64.zip" - }, - "darwin-x64": { - "sha256": "4316f7f98213a173e2356406203359cd4ab5b2b2db36cb219b9d99edec746819", - "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-x64/chrome-headless-shell-mac-x64.zip" - }, - "linux-arm64": { - "sha256": "6bdb4349429ec33e7c0267e0eaa039c2131fa0b3b2997dec0ec96b0d40f579fd", - "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1208/chromium-headless-shell-linux-arm64.zip" - }, - "linux-x64": { - "sha256": "2536e97d8f410df0394b3e7c4252e88ce9f239f04f3af4e247a26caf45baf49e", - "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/linux64/chrome-headless-shell-linux64.zip" - }, - "win32-arm64": { - "sha256": "839500db9e1b0865961ce8b05134ba3d3d71b82be5dec236d6c87ecd93e082cd", - "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-headless-shell-win64.zip" - }, - "win32-x64": { - "sha256": "839500db9e1b0865961ce8b05134ba3d3d71b82be5dec236d6c87ecd93e082cd", - "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-headless-shell-win64.zip" - } - }, - "version": "1208+145.0.7632.6" - }, "cua-driver": { "artifacts": { "darwin-arm64": { diff --git a/pm/lock.py b/pm/lock.py index 94d532132d..d0994a75f9 100644 --- a/pm/lock.py +++ b/pm/lock.py @@ -266,6 +266,14 @@ class Facts: fact["resolved_lock"] = str(resolved_lock.resolve()) self._merge_and_write(name, fact) + def retain(self, names: set[str]) -> None: + """Drop unselected package facts from an exclusively owned staged store.""" + packages = _read(self.path, strict=True)["packages"] + retained = {name: fact for name, fact in packages.items() if name in names} + if retained != packages: + _write(self.path, {"schema": SCHEMA, "packages": retained}) + self._packages = retained + def entries_in_use(self) -> set[str]: return {f["entry"] for f in self._packages.values() if "entry" in f} diff --git a/pm/packages.py b/pm/packages.py index 2e26a69d05..d1e9906beb 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -850,7 +850,7 @@ class CuaDriver(BinaryPackage): class AgentBrowser(BinaryPackage): name = "agent-browser" optional = True - deps = ("chromium", "chromium-headless-shell") + deps = ("chromium",) flatten = True probe_version = False url = "https://registry.npmjs.org/agent-browser/-/agent-browser-{version}.tgz" @@ -890,7 +890,8 @@ class AgentBrowser(BinaryPackage): item.unlink() -class PlaywrightBrowser(Package): +@register +class Chromium(Package): """Playwright resolves browsers by DIRECTORY NAME under one root: `-`, no target suffix. The env points PLAYWRIGHT_BROWSERS_PATH at the store root itself. The entry carries @@ -902,8 +903,10 @@ class PlaywrightBrowser(Package): mirror by revision. The store entry is named by revision only, which is all playwright's resolver reads.""" + name = "chromium" optional = True on_path = False + emulated_arch_targets = frozenset({"win32-arm64"}) _CDN = "https://cdn.playwright.dev" # Chrome-for-Testing platform names; targets absent here fall back to @@ -919,8 +922,6 @@ class PlaywrightBrowser(Package): "win32-arm64": "win64", } _MIRROR = {"linux-arm64": "linux-arm64"} - _FILE = "" # "chrome" / "chrome-headless-shell" - _MIRROR_FILE = "" # "chromium" / "chromium-headless-shell" def store_entry(self, version: str, target: str) -> str: revision = version.partition("+")[0] @@ -930,38 +931,39 @@ class PlaywrightBrowser(Package): revision, _, chrome = version.partition("+") plat = self._CFT.get(target) if plat and chrome: - return f"{self._CDN}/builds/cft/{chrome}/{plat}/{self._FILE}-{plat}.zip" + return f"{self._CDN}/builds/cft/{chrome}/{plat}/chrome-{plat}.zip" mirror_plat = self._MIRROR[target] return ( f"{self._CDN}/dbazure/download/playwright/builds/chromium/" - f"{revision}/{self._MIRROR_FILE}-{mirror_plat}.zip" + f"{revision}/chromium-{mirror_plat}.zip" ) + def binary(self, entry: Path, target: str) -> Optional[Path]: + # CfT and Playwright's ARM Linux archive use different enclosing + # directories. Resolve the executable within the selected entry. + names = {"chrome.exe"} if target.startswith("win32") else {"chrome", "chromium", "Google Chrome for Testing", "Chromium"} + return next((p for p in sorted(entry.rglob("*")) if p.name in names and p.is_file()), None) + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: (staged / "INSTALLATION_COMPLETE").write_text("", encoding="utf-8") def verify(self, entry: Path, target: str) -> str: marker = entry / "INSTALLATION_COMPLETE" - if marker.is_file(): - return "" - return f"INSTALLATION_COMPLETE missing under {entry}; {_entry_listing(entry)}" + if not marker.is_file(): + return f"INSTALLATION_COMPLETE missing under {entry}; {_entry_listing(entry)}" + binary = self.binary(entry, target) + if binary is None: + return f"Chromium executable missing under {entry}" + return self._binary_reason(binary, entry, target) def env(self, entry: Path, target: str) -> dict: - return {"PLAYWRIGHT_BROWSERS_PATH": str(entry.parent)} - - -@register -class Chromium(PlaywrightBrowser): - name = "chromium" - _FILE = "chrome" - _MIRROR_FILE = "chromium" - - -@register -class ChromiumHeadlessShell(PlaywrightBrowser): - name = "chromium-headless-shell" - _FILE = "chrome-headless-shell" - _MIRROR_FILE = "chromium-headless-shell" + binary = self.binary(entry, target) + if binary is None: + raise InstallError(self.name, f"Chromium executable missing under {entry}") + return { + "PLAYWRIGHT_BROWSERS_PATH": str(entry.parent), + "AGENT_BROWSER_EXECUTABLE_PATH": str(binary), + } class LlamaCpp(BinaryPackage): diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index bd4d00c40e..a6b781156a 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -7,7 +7,8 @@ from pathlib import Path from pm.cli import _install_names, _run_live from pm.ensure import _store, _facts, _lockfile, uv as pm_uv -from pm.registry import get_package +from pm.lock import Facts +from pm.registry import get_package, walk from pm.store import current_target def _bundle_package_names() -> list[str]: @@ -25,7 +26,6 @@ def _arch_guard(store_dir: Path) -> list[str]: """Every staged binary must be built for this machine's target — a payload staged with a mismatched interpreter or PATH tool ships an artifact that cannot run. Reads facts, probes each entry binary.""" - from pm.lock import Facts from pm.package import machine_matches_binary facts = Facts(store_dir / "facts.json") @@ -97,10 +97,11 @@ def _stage_native(args) -> int: os.environ["HERMES_RUNTIME_DIR"] = str(store_dir) - names = _bundle_package_names() - failed = _install_names( - [n for n in names if get_package(n).missing_reason(current_target()) is None] - ) + names = [ + n for n in _bundle_package_names() + if get_package(n).missing_reason(current_target()) is None + ] + failed = _install_names(names) # Prune the staged store BEFORE the venv sync and packaging: drop the # fetch- download-cache archives (needed only at install time — dead @@ -110,9 +111,12 @@ def _stage_native(args) -> int: if failed: return 1 # Only this build's store is ours to prune; machine-wide partials are not. - store = _store() - keep = _facts().entries_in_use() - for entry in store.root.iterdir(): + # Cached facts may still name packages removed from the current selection. + # Retain the dependency closure before using facts as the deletion roots. + facts = Facts(store_dir / "facts.json", strict=True) + facts.retain({package.name for package in walk(names)}) + keep = facts.entries_in_use() + for entry in store_dir.iterdir(): if entry.is_dir() and not entry.name.startswith(".") and entry.name not in keep: shutil.rmtree(entry) diff --git a/tests/docker/test_stage2_browser_discovery.py b/tests/docker/test_stage2_browser_discovery.py index 3c4a0130bf..8468cbc99a 100644 --- a/tests/docker/test_stage2_browser_discovery.py +++ b/tests/docker/test_stage2_browser_discovery.py @@ -1,25 +1,21 @@ -"""Runtime smoke tests for Docker stage2 browser executable discovery. +"""Verify Docker exports a runnable full Chromium executable. -Build the real image and verify the chromium binary is actually -discovered at boot: ``AGENT_BROWSER_EXECUTABLE_PATH`` is set, points to -a real executable, and is a browser binary (not a shared library picked -up by a broad ``find | grep``). +The image must ship no separate headless-shell package or store entry. """ from __future__ import annotations -from tests.docker.conftest import docker_exec_sh, start_container +import json + +from tests.docker.conftest import docker_exec, docker_exec_sh, start_container def test_stage2_discovers_chromium_binary( built_image: str, container_name: str, ) -> None: - """The stage2 hook must discover the Playwright chromium binary and - export AGENT_BROWSER_EXECUTABLE_PATH so the browser tool can find it. + """Stage2 must export the baked full-browser path, not a shell or .so. - The discovery uses filename matching, not a broad ``find | grep``: - shared libraries (libGLESv2.so etc.) inherit the executable bit from - Playwright's tarball but must not be picked up. This test verifies the - discovered binary is a real browser, not a .so. + Exercise it as the runtime user: an executable bit alone does not prove + Chromium can load its shared libraries. """ start_container(built_image, container_name) @@ -46,10 +42,7 @@ def test_stage2_discovers_chromium_binary( ) # Must be a browser binary by basename — NOT a shared library. - accepted_names = ( - "chrome", "chromium", "chrome-headless-shell", - "headless_shell", "chromium-browser", - ) + accepted_names = ("chrome", "chromium", "chromium-browser") r = docker_exec_sh( container_name, f'basename "{browser_path}"', @@ -59,7 +52,30 @@ def test_stage2_discovers_chromium_binary( assert basename in accepted_names, ( f"discovered binary basename {basename!r} is not a recognized " f"browser name (accepted: {accepted_names}) — the discovery may " - f"have picked up a shared library (.so) instead of the real browser" + f"have picked up a shell or shared library instead of full Chromium" ) + r = docker_exec( + container_name, + "python3", "-c", + "import json; from pathlib import Path; " + "root = Path('/opt/hermes/tools'); " + "packages = json.loads((root / 'facts.json').read_text())['packages']; " + "print(json.dumps({'packages': list(packages), 'shell_entries': " + "[p.name for p in root.glob('*headless*shell*')]}))", + timeout=10, + ) + assert r.returncode == 0, f"cannot inspect installed browser packages: {r.stderr}" + inventory = json.loads(r.stdout) + assert "chromium" in inventory["packages"], inventory + assert "chromium-headless-shell" not in inventory["packages"], inventory + assert not inventory["shell_entries"], inventory + r = docker_exec( + container_name, + browser_path, + "--version", + timeout=10, + ) + assert r.returncode == 0, f"full Chromium executable failed: {r.stderr}" + assert "Chrome" in r.stdout or "Chromium" in r.stdout, r.stdout diff --git a/tests/hermes_cli/test_browser_runtime.py b/tests/hermes_cli/test_browser_runtime.py new file mode 100644 index 0000000000..834a5dfcc6 --- /dev/null +++ b/tests/hermes_cli/test_browser_runtime.py @@ -0,0 +1,66 @@ +"""Browser resolution uses installed PM facts without provisioning a browser.""" + +import os + +import pytest + +import pm +from pm import paths + + +@pytest.mark.parametrize("record_executable", [False, True]) +def test_chromium_resolves_installed_binary_without_mutation(tmp_path, monkeypatch, record_executable): + from hermes_cli.browser_runtime import chromium_executable + + home = tmp_path / "home" + store = home / "tools" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) + lock = pm.Lockfile(paths.lockfile_path()) + target = pm.current_target() + package = pm.get_package("chromium") + version = lock.version("chromium") + assert version is not None + entry = store / package.store_entry(version, target) + binary = entry / "browser" / ("chrome.exe" if target.startswith("win32") else "chrome") + binary.parent.mkdir(parents=True) + binary.write_bytes(b"browser-fixture") + binary.chmod(0o755) + env = {"PLAYWRIGHT_BROWSERS_PATH": str(store)} + if record_executable: + env["AGENT_BROWSER_EXECUTABLE_PATH"] = str(binary) + facts = pm.Facts(store / "facts.json") + facts.record( + "chromium", version, entry.name, env, store, + target=target, artifacts=[a["sha256"] for a in lock.artifacts("chromium", target)], + ) + before = facts.path.read_bytes() + environment = dict(os.environ) + + assert chromium_executable() == str(binary) + assert facts.path.read_bytes() == before + assert dict(os.environ) == environment + + +def test_chromium_override_wins_without_installing(tmp_path, monkeypatch): + from hermes_cli.browser_runtime import chromium_executable + + store = tmp_path / "missing-store" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + override = str(tmp_path / "external browser") + monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", override) + + def forbidden(*args, **kwargs): + pytest.fail("browser resolution must not provision or activate packages") + + monkeypatch.setattr(pm, "ensure", forbidden) + monkeypatch.setattr(pm, "activate", forbidden) + with monkeypatch.context() as scoped: + scoped.setattr(pm, "installed_package", forbidden) + assert chromium_executable() == override + monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH") + assert chromium_executable() is None + assert not store.exists() + assert "AGENT_BROWSER_EXECUTABLE_PATH" not in os.environ diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 15928f1428..65d66b0f64 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -166,4 +166,4 @@ def test_setup_summary_local_browser_unavailable_without_chromium( output = capsys.readouterr().out assert "Browser Automation (Local browser)" not in output - assert "agent-browser install --with-deps" in output + assert "hermes pm install agent-browser" in output diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 791219ee2b..9c1fed0746 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest -from tools.browser_tool import AGENT_BROWSER_NPX_SPEC from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo from hermes_cli.nous_subscription import NousSubscriptionFeatures from hermes_cli.tools_config import ( @@ -227,12 +226,13 @@ def test_save_platform_tools_preserves_mcp_server_names(): -def test_first_install_nous_auto_configures_video_gen(monkeypatch): +def test_first_install_nous_auto_configures_video_gen(monkeypatch, tmp_path): """When a Nous subscriber checks video_gen in the toolset checklist, apply_nous_managed_defaults must write video_gen.provider and video_gen.use_gateway so the FAL plugin can route through the gateway at runtime. Regression test for the bug where video_gen was marked as auto-configured but no config was actually written.""" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) config = { "model": {"provider": "nous"}, @@ -375,13 +375,8 @@ def test_numeric_mcp_server_name_does_not_crash_sorted(): class TestAgentBrowserPostSetup: """_run_post_setup('agent_browser'/'browserbase') — #43564. - agent-browser is no longer a root package.json dependency (there's no - local `npm install` step anymore); it resolves at runtime via - tools.browser_tool_install._find_agent_browser (PATH -> Homebrew/Hermes-managed - node -> local .bin -> npx). This class exercises the Chromium-install - branch of _run_post_setup, which now delegates to that same resolution - cascade instead of hand-rolling its own node_modules/.bin/agent-browser - (and Windows .cmd-shim) lookup. + CLI readiness uses the runtime resolution cascade. Binary installation + belongs to pm; npx is only used for apt system dependencies. """ @pytest.fixture(autouse=True) @@ -394,6 +389,11 @@ class TestAgentBrowserPostSetup: with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub: yield stub + @pytest.fixture(autouse=True) + def _stub_chromium_install(self): + with patch("pm.ensure") as ensure: + yield ensure + def test_warns_when_neither_npx_nor_agent_browser_on_path(self): with patch("shutil.which", return_value=None), patch( "subprocess.run" @@ -473,15 +473,16 @@ class TestAgentBrowserPostSetup: docker_check.assert_not_called() assert any("browser tools require Node.js" in c.args[0] for c in warn.call_args_list) - def test_installs_chromium_via_npx_when_no_local_binary_resolved(self): - """When _find_agent_browser falls through to npx, the install command - must shell out to npx directly (not the unresolved 'npx agent-browser' - string as a single argv element).""" + @pytest.mark.platforms("linux") + @pytest.mark.parametrize("returncode", [0, 1]) + def test_installs_only_system_dependencies_via_npx(self, _stub_chromium_install, returncode): + """Apt failure must not cause a second browser download or change global env.""" + import os with patch( "shutil.which", # accepts the `path=` kwarg _resolve_npx_bin's extended-path rung # calls shutil.which with, not just the bare-PATH positional form. - side_effect=lambda name, path=None: "/usr/bin/npx" if name == "npx" else None, + side_effect=lambda name, path=None: f"/usr/bin/{name}" if name in {"npx", "apt-get"} else None, ), patch( "tools.browser_tool_install.node_tool_runnable", return_value=True ), patch("subprocess.run") as run, patch( @@ -492,23 +493,27 @@ class TestAgentBrowserPostSetup: "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" ), patch( "hermes_cli.tools_config_post_setup._print_success" - ): - run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") + ), patch("hermes_cli.tools_config_post_setup._print_warning") as warn: + run.return_value = SimpleNamespace(returncode=returncode, stdout="", stderr="apt failed") + before = dict(os.environ) _run_post_setup("agent_browser") + assert dict(os.environ) == before run.assert_called_once() assert run.call_args.args[0] == [ - "/usr/bin/npx", "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", "--with-deps", + "/usr/bin/npx", "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium", ] + _stub_chromium_install.assert_called_once_with("chromium", explicit=True) + if returncode: + assert any("system dependency install failed" in c.args[0] for c in warn.call_args_list) + else: + warn.assert_not_called() - def test_installs_chromium_via_npx_resolved_only_through_extended_path(self): - """Hermes-managed-Node-only setups: npx resolves via - _find_agent_browser's extended-PATH fallback, not a bare PATH lookup. - The install command must use that same resolved npx, not silently - hand subprocess.run a None argument from a bare shutil.which('npx') - re-derivation (#43564 regression — Copilot review, task #9).""" + @pytest.mark.platforms("linux") + def test_installs_system_dependencies_via_managed_npx(self): + """Dependency setup must reuse runtime npx resolution, including managed Node.""" hermes_npx = "/home/user/.hermes/node/bin/npx" - with patch("shutil.which", return_value=None), patch( + with patch("shutil.which", side_effect=lambda name: "/usr/bin/apt-get" if name == "apt-get" else None), patch( "subprocess.run" ) as run, patch( "tools.browser_tool_install._chromium_installed", return_value=False @@ -526,15 +531,14 @@ class TestAgentBrowserPostSetup: run.assert_called_once() assert run.call_args.args[0] == [ - hermes_npx, "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", "--with-deps", + hermes_npx, "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium", ] + assert run.call_args.kwargs["env"]["PATH"].startswith("/home/user/.hermes/node/bin:") - def test_warns_instead_of_crashing_when_npx_unresolvable_after_all(self): - """Defensive: if _resolve_npx_bin somehow returns None even though - _find_agent_browser resolved "npx agent-browser" (e.g. a race where - npx disappears between the two calls), warn and return instead of - building a command with a None argv element.""" - with patch("shutil.which", return_value=None), patch( + @pytest.mark.platforms("linux") + def test_warns_when_system_dependency_installer_is_unavailable(self, _stub_chromium_install): + """A missing npx must not prevent the managed Chromium download.""" + with patch("shutil.which", return_value="/usr/bin/apt-get"), patch( "subprocess.run" ) as run, patch( "tools.browser_tool_install._chromium_installed", return_value=False @@ -550,31 +554,32 @@ class TestAgentBrowserPostSetup: _run_post_setup("agent_browser") # must not raise run.assert_not_called() + _stub_chromium_install.assert_called_once_with("chromium", explicit=True) assert any("npx not found" in c.args[0] for c in warn.call_args_list) - def test_installs_chromium_via_resolved_local_binary_path(self): - """When _find_agent_browser resolves a concrete executable (global - install, Homebrew, or the Windows .cmd shim it already knows how to - pick), that path must be invoked directly — not re-wrapped in npx.""" - with patch("shutil.which", return_value="/usr/bin/npx"), patch( + @pytest.mark.parametrize("browser_cmd", ["/usr/local/bin/agent-browser", "npx agent-browser"]) + def test_installs_chromium_via_pm_without_mutating_environment(self, browser_cmd): + """Both CLI resolution paths must use the full-Chromium package, not + agent-browser's installer (which also downloads headless shell).""" + import os + + with patch("shutil.which", return_value=None), patch( "subprocess.run" ) as run, patch( "tools.browser_tool_install._chromium_installed", return_value=False ), patch( "tools.browser_tool_install._running_in_docker", return_value=False ), patch( - "tools.browser_tool_install._find_agent_browser", - return_value="/usr/local/bin/agent-browser", - ), patch( - "hermes_cli.tools_config_post_setup._print_success" - ): + "tools.browser_tool_install._find_agent_browser", return_value=browser_cmd + ), patch("pm.ensure") as ensure: + ensure.return_value.env = {"PLAYWRIGHT_BROWSERS_PATH": "/managed/chromium"} run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") + before = dict(os.environ) _run_post_setup("agent_browser") + assert dict(os.environ) == before - run.assert_called_once() - assert run.call_args.args[0] == [ - "/usr/local/bin/agent-browser", "install", "--with-deps", - ] + ensure.assert_called_once_with("chromium", explicit=True) + run.assert_not_called() def test_install_success_invalidates_chromium_cache(self): import tools.browser_tool as _bt @@ -601,16 +606,14 @@ class TestAgentBrowserPostSetup: "result so the next check_browser_requirements() call re-probes" ) - def test_install_failure_prints_stderr_tail_and_does_not_invalidate_cache(self): + def test_install_failure_reports_pm_error_and_does_not_invalidate_cache(self): + import pm import tools.browser_tool as _bt with patch("shutil.which", return_value="/usr/bin/npx"), patch( "tools.browser_tool_install.node_tool_runnable", return_value=True ), patch( - "subprocess.run", - return_value=SimpleNamespace( - returncode=1, stdout="", stderr="line1\nline2\nfatal: network error" - ), + "pm.ensure", side_effect=pm.InstallError("chromium", "fatal: network error") ), patch( "tools.browser_tool_install._chromium_installed", return_value=False ), patch( @@ -626,7 +629,8 @@ class TestAgentBrowserPostSetup: _run_post_setup("agent_browser") assert any("Chromium install failed" in c.args[0] for c in warn.call_args_list) - assert any("fatal: network error" in c.args[0] for c in info.call_args_list) + assert any("fatal: network error" in c.args[0] for c in warn.call_args_list) + assert any("hermes pm install chromium" in c.args[0] for c in info.call_args_list) assert _bt._cached_chromium_installed == "sentinel", ( "a failed install must not invalidate the chromium cache" ) @@ -635,8 +639,8 @@ class TestAgentBrowserPostSetup: with patch("shutil.which", return_value="/usr/bin/npx"), patch( "tools.browser_tool_install.node_tool_runnable", return_value=True ), patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd=["npx"], timeout=600), + "pm.ensure", + side_effect=subprocess.TimeoutExpired(cmd=["chromium"], timeout=600), ), patch( "tools.browser_tool_install._chromium_installed", return_value=False ), patch( diff --git a/tests/pm/test_chromium.py b/tests/pm/test_chromium.py new file mode 100644 index 0000000000..9a397fcf52 --- /dev/null +++ b/tests/pm/test_chromium.py @@ -0,0 +1,41 @@ +"""One browser payload serves both headed and headless callers.""" +from pathlib import Path + +import pytest + +from pm.lock import Facts +from pm.packages import Chromium +from pm.registry import walk +from scripts.bundles.native import _bundle_package_names + + +def test_browser_closure_and_bundle_ship_full_chromium_only(): + closure = {package.name for package in walk(["agent-browser"])} + bundled = set(_bundle_package_names()) + assert "chromium" in closure & bundled + assert "chromium-headless-shell" not in closure | bundled + + +@pytest.mark.parametrize(("target", "relative"), [ + ("linux-x64", "chrome-linux64/chrome"), + ("linux-arm64", "chrome-linux/chrome"), + ("darwin-arm64", "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"), + ("win32-x64", "chrome-win64/chrome.exe"), + ("win32-arm64", "chrome-win64/chrome.exe"), +]) +def test_chromium_binary_and_env_survive_store_relocation(tmp_path, target, relative): + package = Chromium() + store = tmp_path / "store" + entry = store / package.store_entry("1234+145.0.0.0", target) + executable = entry / relative + executable.parent.mkdir(parents=True) + executable.write_bytes(b"browser-fixture") + executable.chmod(0o755) + assert package.binary(entry, target) == executable + facts = Facts(store / "facts.json") + facts.record("chromium", "1234+145.0.0.0", entry.name, package.env(entry, target), store) + moved = tmp_path / "relocated" + store.rename(moved) + env = Facts(moved / "facts.json").env_for("chromium", moved) + assert Path(env["AGENT_BROWSER_EXECUTABLE_PATH"]) == moved / entry.name / relative + assert env["PLAYWRIGHT_BROWSERS_PATH"] == str(moved) diff --git a/tests/pm/test_pm_core.py b/tests/pm/test_pm_core.py index 1d1afcafab..0bb83ead6a 100644 --- a/tests/pm/test_pm_core.py +++ b/tests/pm/test_pm_core.py @@ -667,7 +667,7 @@ def test_bundle_package_names_include_browsers(monkeypatch, tmp_path): from pm.lock import Lockfile lock = Lockfile(tmp_path / "lock.json") - for name in ("uv", "python", "ripgrep", "chromium", "chromium-headless-shell", "node", "npm"): + for name in ("uv", "python", "ripgrep", "chromium", "node", "npm"): lock.set_pin(name, "1", {"any": {"url": "x", "sha256": "0" * 64}}) lock.save() monkeypatch.setattr("scripts.bundles.native._lockfile", lambda: lock) @@ -675,7 +675,6 @@ def test_bundle_package_names_include_browsers(monkeypatch, tmp_path): # Browsers now ship in every payload (win32-arm64 runs the x64 build # under emulation); nothing is excluded from the bundle. assert "chromium" in names - assert "chromium-headless-shell" in names assert "python" in names assert "ripgrep" in names # node/npm are shipped runtime tools (TUI, plugins), not install diff --git a/tests/scripts/test_bundle_store_cleanup.py b/tests/scripts/test_bundle_store_cleanup.py new file mode 100644 index 0000000000..543f9a49b4 --- /dev/null +++ b/tests/scripts/test_bundle_store_cleanup.py @@ -0,0 +1,55 @@ +"""Reusing a bundle cache must not ship packages removed from its selection.""" +from types import SimpleNamespace + +from pm.lock import Facts, Lockfile +from scripts.bundles import native + + +def test_cached_bundle_prunes_unselected_facts_and_entries(tmp_path, monkeypatch): + output = tmp_path / "payload" + staged_store = output / "tools" + user_store = tmp_path / "user-tools" + selected = {"agent-browser", "chromium", "uv", "python"} + stale = {"chromium-headless-shell", "retired-tool"} + for root in (staged_store, user_store): + root.mkdir(parents=True) + facts = Facts(root / "facts.json") + for name in selected | stale: + entry = f"cached-{name}" + (root / entry).mkdir() + (root / entry / "payload").write_text(name, encoding="utf-8") + facts.record(name, "fixture", entry, {}, root) + (root / "orphaned-version").mkdir() + (root / ".partials").mkdir() + user_facts_before = (user_store / "facts.json").read_bytes() + user_entries_before = {p.name for p in user_store.iterdir()} + + # Chromium is a dependency, not a selected root; Python is supplied by + # bundle selection and uv must survive despite being internal. + lock = Lockfile(tmp_path / "lock.json") + for name in ("agent-browser", "uv"): + lock.set_pin(name, "fixture", {}) + lock.save() + monkeypatch.setattr(native, "_lockfile", lambda: lock) + monkeypatch.setattr("scripts.bundles.payload.snapshot", lambda *args: None) + monkeypatch.setattr(native, "_install_names", lambda names: 0) + # Stop after cleanup, before invoking any venv/build subprocesses. + monkeypatch.setattr(native, "pm_uv", lambda: (None, {})) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(user_store)) + + assert native.stage_native(SimpleNamespace(out=str(output), ref="HEAD")) == 1 + + remaining = Facts(staged_store / "facts.json") + for name in stale: + assert remaining.get(name) is None + assert not (staged_store / f"cached-{name}").exists() + for name in selected: + fact = remaining.get(name) + assert fact is not None + assert fact["entry"] == f"cached-{name}" + assert (staged_store / f"cached-{name}" / "payload").read_text(encoding="utf-8") == name + assert not (staged_store / "orphaned-version").exists() + assert (staged_store / ".partials").is_dir() + assert native._store().root == user_store + assert (user_store / "facts.json").read_bytes() == user_facts_before + assert {p.name for p in user_store.iterdir()} == user_entries_before diff --git a/tests/tools/test_browser_chromium_autoinstall.py b/tests/tools/test_browser_chromium_autoinstall.py index a7fe499638..61ce6a3658 100644 --- a/tests/tools/test_browser_chromium_autoinstall.py +++ b/tests/tools/test_browser_chromium_autoinstall.py @@ -1,18 +1,13 @@ -"""Tests for gated Chromium-binary auto-install on local cold start.""" - -import shutil -from types import SimpleNamespace - +"""Local cold starts provision Chromium through the pinned package manager.""" import pytest -import importlib - -import tools.browser_tool as bt +from pm.package import InstallError +from tools import browser_tool as bt from tools import browser_tool_install as bt_install @pytest.fixture(autouse=True) -def _reset_state(): +def reset_state(): bt._chromium_autoinstall_attempted = False bt._cached_chromium_installed = None yield @@ -20,94 +15,41 @@ def _reset_state(): bt._cached_chromium_installed = None -def _no_subprocess(monkeypatch): +def test_install_uses_pm_once_and_preserves_failure(monkeypatch): + monkeypatch.setattr(bt_install, "_running_in_docker", lambda: False) + monkeypatch.setattr("pm.lazy_installs_allowed", lambda: True) + installed = False calls = [] - monkeypatch.setattr(bt.subprocess, "run", lambda *a, **k: calls.append((a, k))) - return calls + + def ensure(name): + nonlocal installed + calls.append(name) + installed = True + + monkeypatch.setattr("pm.ensure", ensure) + monkeypatch.setattr(bt_install, "_chromium_installed", lambda: installed) + assert bt_install._maybe_autoinstall_chromium() + assert bt_install._maybe_autoinstall_chromium() + assert calls == ["chromium"] + + bt._chromium_autoinstall_attempted = False + installed = False + + def fail(name): + raise InstallError(name, "download failed") + + monkeypatch.setattr("pm.ensure", fail) + assert not bt_install._maybe_autoinstall_chromium() -class TestGating: - def test_disabled_lazy_installs_skips(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - monkeypatch.setattr("pm.lazy_installs_allowed", lambda: False) - calls = _no_subprocess(monkeypatch) - assert bt_install._maybe_autoinstall_chromium() is False - assert calls == [] +@pytest.mark.parametrize(("docker", "allowed"), [(True, True), (False, False)]) +def test_install_policy_never_provisions(monkeypatch, docker, allowed): + monkeypatch.setattr(bt_install, "_running_in_docker", lambda: docker) + monkeypatch.setattr("pm.lazy_installs_allowed", lambda: allowed) - def test_docker_skips(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: True) - calls = _no_subprocess(monkeypatch) - assert bt_install._maybe_autoinstall_chromium() is False - assert calls == [] + def forbidden(*args, **kwargs): + pytest.fail("blocked auto-install reached provisioning") - -class TestInstall: - def test_success_installs_binary_only_and_rechecks(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - monkeypatch.setattr("pm.lazy_installs_allowed", lambda: True) - monkeypatch.setattr("tools.browser_tool_install._find_agent_browser", lambda: "/x/agent-browser") - monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) - monkeypatch.setattr("tools.browser_tool_install._chromium_installed", lambda: True) - - captured = {} - - def fake_run(cmd, **kw): - captured["cmd"] = cmd - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr(bt.subprocess, "run", fake_run) - - assert bt_install._maybe_autoinstall_chromium() is True - assert captured["cmd"] == ["/x/agent-browser", "install"] - assert "--with-deps" not in captured["cmd"] - - def test_npx_form_is_binary_only(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - monkeypatch.setattr("pm.lazy_installs_allowed", lambda: True) - monkeypatch.setattr("tools.browser_tool_install._find_agent_browser", lambda: "npx agent-browser") - monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) - monkeypatch.setattr("tools.browser_tool_install._chromium_installed", lambda: True) - monkeypatch.setattr(shutil, "which", lambda _, path=None: "/usr/bin/npx") - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: True) - - captured = {} - monkeypatch.setattr( - bt.subprocess, "run", - lambda cmd, **kw: captured.update(cmd=cmd) or SimpleNamespace(returncode=0, stdout="", stderr=""), - ) - - assert bt_install._maybe_autoinstall_chromium() is True - assert captured["cmd"] == [ - "/usr/bin/npx", "--ignore-scripts", "-y", bt.AGENT_BROWSER_NPX_SPEC, "install", - ] - assert "--with-deps" not in captured["cmd"] - - def test_nonzero_exit_returns_false(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - monkeypatch.setattr("pm.lazy_installs_allowed", lambda: True) - monkeypatch.setattr("tools.browser_tool_install._find_agent_browser", lambda: "/x/agent-browser") - monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) - monkeypatch.setattr( - bt.subprocess, "run", - lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="boom"), - ) - assert bt_install._maybe_autoinstall_chromium() is False - - -class TestOneShot: - def test_second_call_does_not_reinstall(self, monkeypatch): - monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - monkeypatch.setattr("pm.lazy_installs_allowed", lambda: True) - monkeypatch.setattr("tools.browser_tool_install._find_agent_browser", lambda: "/x/agent-browser") - monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) - monkeypatch.setattr("tools.browser_tool_install._chromium_installed", lambda: True) - - runs = [] - monkeypatch.setattr( - bt.subprocess, "run", - lambda *a, **k: runs.append(1) or SimpleNamespace(returncode=0, stdout="", stderr=""), - ) - - assert bt_install._maybe_autoinstall_chromium() is True - assert bt_install._maybe_autoinstall_chromium() is True - assert len(runs) == 1 + monkeypatch.setattr("pm.ensure", forbidden) + monkeypatch.setattr(bt_install, "_find_agent_browser", forbidden) + assert not bt_install._maybe_autoinstall_chromium() diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index 17ca41045e..e582ca591d 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -17,7 +17,8 @@ from tools import browser_tool_cloud as bt_cloud @pytest.fixture(autouse=True) -def _reset_chromium_cache(): +def _reset_chromium_cache(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) bt._cached_chromium_installed = None yield bt._cached_chromium_installed = None @@ -38,6 +39,12 @@ class TestChromiumSearchRoots: class TestChromiumInstalled: + def test_shell_only_cache_does_not_satisfy_full_browser(self, monkeypatch, tmp_path): + monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) + monkeypatch.setattr(bt_install, "_chromium_search_roots", lambda: [str(tmp_path)]) + (tmp_path / "chromium_headless_shell-1234").mkdir() + assert bt_install._chromium_installed() is False + def test_system_chromium_on_path_alone_is_not_enough(self, monkeypatch, tmp_path): """Pinned-store-only (gap plan D3): a system Chromium in PATH does NOT satisfy the check — only AGENT_BROWSER_EXECUTABLE_PATH or the diff --git a/tests/tools/test_browser_headed_mode.py b/tests/tools/test_browser_headed_mode.py index 07f29f0f0e..0f6316c988 100644 --- a/tests/tools/test_browser_headed_mode.py +++ b/tests/tools/test_browser_headed_mode.py @@ -20,7 +20,12 @@ def _reset_headed_cache(): @pytest.fixture(autouse=True) -def _clean_headed_cache(): +def _clean_headed_cache(monkeypatch, tmp_path): + from pathlib import Path + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) _reset_headed_cache() yield _reset_headed_cache() diff --git a/tests/tools/test_browser_open_timeout.py b/tests/tools/test_browser_open_timeout.py index 294dc7e8b8..6f8a5073d3 100644 --- a/tests/tools/test_browser_open_timeout.py +++ b/tests/tools/test_browser_open_timeout.py @@ -13,7 +13,8 @@ from tools import browser_tool_install as bt_install @pytest.fixture(autouse=True) -def _reset_browser_caches(): +def _reset_browser_caches(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) bt._cached_command_timeout = None bt._command_timeout_resolved = False bt._active_sessions.clear() @@ -74,11 +75,15 @@ class TestTimeoutErrorFormatting: assert "Daemon process exited" in err - def test_local_install_hint(self, monkeypatch): + @pytest.mark.parametrize("stderr", ["", "Chromium sandbox launch failed"]) + def test_local_install_hint(self, monkeypatch, stderr): monkeypatch.setattr("tools.browser_tool_cloud._is_local_mode", lambda: True) monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False) - err = bt_session._format_browser_timeout_error("open", 60, "", "") - assert "agent-browser install --with-deps" in err + err = bt_session._format_browser_timeout_error("open", 60, "", stderr) + assert "playwright install-deps chromium" in err + assert "agent-browser install" not in err + if not stderr: + assert "hermes pm install chromium" in err class TestReadCommandOutputFiles: diff --git a/tools/browser_tool_install.py b/tools/browser_tool_install.py index 987d941397..be0696606d 100644 --- a/tools/browser_tool_install.py +++ b/tools/browser_tool_install.py @@ -55,7 +55,7 @@ def _merge_browser_path(existing_path: str = "") -> str: def _browser_install_hint() -> str: - return "npm install -g agent-browser && agent-browser install" + ("" if _is_termux_environment() else " --with-deps") + return "npm install -g agent-browser && agent-browser install" if _is_termux_environment() else "hermes pm install agent-browser (system libraries: npx playwright install-deps chromium)" def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: @@ -203,7 +203,7 @@ def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: def _chromium_search_roots() -> List[str]: - """Chromium / headless-shell scan roots in agent-browser/Playwright probe order: ``PLAYWRIGHT_BROWSERS_PATH``, then the per-OS default cache.""" + """Full Chromium scan roots: ``PLAYWRIGHT_BROWSERS_PATH``, then the per-OS default cache.""" env_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "").strip() home = os.path.expanduser("~") roots: List[str] = [env_path] if env_path and env_path != "0" else [] @@ -217,15 +217,15 @@ def _chromium_search_roots() -> List[str]: def _has_chromium_build(root: str) -> bool: - """True when ``root`` holds a Playwright ``chromium-*`` / ``chromium_headless_shell-*`` dir (agent-browser accepts either).""" + """True when ``root`` holds a full Playwright ``chromium-*`` build.""" try: - return any(e.startswith(("chromium-", "chromium_headless_shell-")) for e in os.listdir(root)) + return any(e.startswith("chromium-") for e in os.listdir(root)) except OSError: return False def _chromium_installed() -> bool: - """True when a usable Chromium (or headless-shell) build is on disk; cached. + """True when a full Chromium build is on disk; cached. Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then the provisioned Playwright cache. Without a binary the CLI hangs on first use until the command timeout fires, so the tool must not be advertised. @@ -233,7 +233,9 @@ def _chromium_installed() -> bool: _bt = _origin() if _bt._cached_chromium_installed is not None: return _bt._cached_chromium_installed - ab_path = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH", "").strip() + from hermes_cli.browser_runtime import chromium_executable + + ab_path = chromium_executable() _bt._cached_chromium_installed = bool( (ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path))) or any(root and os.path.isdir(root) and _has_chromium_build(root) for root in _chromium_search_roots()) @@ -242,10 +244,9 @@ def _chromium_installed() -> bool: def _maybe_autoinstall_chromium() -> bool: - """Best-effort, gated download of the Chromium *binary* on local cold start. + """Install only PM's pinned full Chromium, never the upstream browser pair. - Binary only (``agent-browser install``), never ``--with-deps`` — that shells ``apt`` and needs root. Gated by - ``security.allow_lazy_installs``, skipped in Docker (Chromium ships in the image), attempted once per process. + Docker supplies the binary. Other installs require lazy-install consent. """ _bt = _origin() if _bt._chromium_autoinstall_attempted: @@ -253,27 +254,14 @@ def _maybe_autoinstall_chromium() -> bool: _bt._chromium_autoinstall_attempted = True if _running_in_docker(): return False - from pm import lazy_installs_allowed + from pm import InstallError, ensure, lazy_installs_allowed if not lazy_installs_allowed(): return False + _bt.logger.info("browser: installing PM's pinned Chromium") try: - browser_cmd = _find_agent_browser() - except FileNotFoundError: - return False - install_cmd = [browser_cmd, "install"] - if _is_npx_agent_browser_sentinel(browser_cmd): - install_cmd = [_resolve_npx_bin() or "npx", "--ignore-scripts", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "install"] - - _bt.logger.info("browser: Chromium missing — auto-installing the browser binary (one-time ~170MB; disable via security.allow_lazy_installs)") - try: - proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, - env=_bt._build_browser_env(), stdin=subprocess.DEVNULL) - except (OSError, subprocess.SubprocessError) as e: - _bt.logger.warning("browser: Chromium auto-install failed to start: %s", e) - return False - if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip()[-300:] - _bt.logger.warning("browser: Chromium auto-install exited %s: %s", proc.returncode, tail) + ensure("chromium") + except (InstallError, OSError) as exc: + _bt.logger.warning("browser: Chromium auto-install failed: %s", exc) return False _bt._cached_chromium_installed = None return _chromium_installed() diff --git a/tools/browser_tool_lightpanda_fallback.py b/tools/browser_tool_lightpanda_fallback.py index 561a56939e..4abecebeb8 100644 --- a/tools/browser_tool_lightpanda_fallback.py +++ b/tools/browser_tool_lightpanda_fallback.py @@ -144,7 +144,7 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti "pull the latest image: docker pull ghcr.io/nousresearch/hermes-agent:latest") else: hint = ("Chrome fallback requires Chromium, but it is missing. Install it with: " - "npx agent-browser install --with-deps (or: npx playwright install --with-deps chromium)") + "hermes pm install chromium") return {"success": False, "error": hint} base_args = _session._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"] diff --git a/tools/browser_tool_session.py b/tools/browser_tool_session.py index 4cb9040d54..1dd189d9ad 100644 --- a/tools/browser_tool_session.py +++ b/tools/browser_tool_session.py @@ -24,7 +24,7 @@ from tools import browser_tool_real_profile as _real_profile from tools import browser_tool_snapshot as _snapshot _DOCKER_PULL = "docker pull ghcr.io/nousresearch/hermes-agent:latest" -_CHROMIUM_INSTALL = "npx agent-browser install --with-deps (or: npx playwright install --with-deps chromium)" +_CHROMIUM_INSTALL = "hermes pm install chromium (system libraries: npx playwright install-deps chromium)" _CHROMIUM_MISSING_DOCKER_HINT = ("Chromium browser is missing. You're running in Docker — pull the latest image " f"to get the bundled Chromium: {_DOCKER_PULL}") _CHROMIUM_MISSING_HINT = f"Chromium browser is missing. Install it with: {_CHROMIUM_INSTALL}" @@ -83,7 +83,7 @@ def _format_browser_timeout_error( if "sandbox" in f"{stderr}\n{stdout}".lower(): parts.append("Chromium sandbox launch failed. Set AGENT_BROWSER_ARGS=" "'--no-sandbox,--disable-dev-shm-usage' in your environment, " - "or run: npx agent-browser install --with-deps") + "or run: npx playwright install-deps chromium") elif command == "open" and _cloud._is_local_mode(): if _install._running_in_docker(): parts.append("The browser daemon may still be starting or Chromium may be " @@ -123,6 +123,11 @@ def _agent_browser_command_env(socket_dir: str) -> Dict[str, str]: daemon-side idle self-termination (agent-browser 0.24+) mirroring the Python janitor unless the user set ``AGENT_BROWSER_IDLE_TIMEOUT_MS`` explicitly.""" env = _bt._build_browser_env() + from hermes_cli.browser_runtime import chromium_executable + + executable = chromium_executable() + if executable: + env["AGENT_BROWSER_EXECUTABLE_PATH"] = executable env["PATH"] = _install._merge_browser_path(env.get("PATH", "")) env["AGENT_BROWSER_SOCKET_DIR"] = socket_dir if "AGENT_BROWSER_IDLE_TIMEOUT_MS" not in env: diff --git a/website/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client.md b/website/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client.md index 01024b762e..845ce8b6d6 100644 --- a/website/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client.md +++ b/website/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client.md @@ -58,7 +58,7 @@ HAR recording works differently in each case (see How to Run). ## Prerequisites - Playwright + a browser binary (capture step only): - - `pip install playwright` then `playwright install chromium` + - `pip install playwright` then `playwright install chromium --no-shell` - (If a system Playwright already has browsers under `~/.cache/ms-playwright`, reuse it.) - `requests` or `httpx` for the replay step (stdlib `urllib` also works). - No API keys. Any keys/tokens the client needs are the ones the HAR captured.