From 6756d11b5f4f2fcbf0543aa275d5768e97acd6a2 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 10 Sep 2026 12:02:06 -0400 Subject: [PATCH 1/2] fix(pm): ship full chromium without headless shell Full Chromium serves both headed and headless sessions. The separate shell duplicates the browser payload and is not needed for either mode. Remove the shell from PM and Docker. Select the managed Chromium executable for agent-browser and the full Chromium channel for direct Playwright callers. Route setup through PM and remove retired packages from cached bundle stores without changing the user's tool store. Update signing, architecture checks, launch probes and install guidance. Leave llama packages and Docker archive cleanup unchanged. Verification: - Real agent-browser navigation, clicks, DOM reads and screenshots pass in headed and headless modes with the same Chromium executable. - The direct Playwright doctor probe passes. - Focused Python and desktop packaging tests pass, as do six Docker checks and both real-browser task-scroll tests. - The built linux/amd64 image is 1.393 GB compressed, 223.6 MB smaller. - The broader PM suite and two unrelated setup tests still fail. Those failures reproduce on unchanged HEAD. - Five updated eval scripts parse; their full scenarios were not run. --- Dockerfile | 9 +- apps/desktop/scripts/audit-bundle-arch.mjs | 14 +- .../scripts/audit-bundle-arch.test.mjs | 23 +-- apps/desktop/scripts/sign-nested-chromium.mjs | 4 +- .../scripts/sign-nested-chromium.test.mjs | 10 +- apps/desktop/scripts/tasks-scroll.test.mjs | 2 +- docker/stage2-hook.sh | 2 +- docs/shared-bundle-builds.md | 6 + .../async-report-live.mjs | 2 +- .../navigation-markdown-live.mjs | 2 +- .../thread-scroll/ownership-probe.mjs | 2 +- .../thread-scroll/probe.mjs | 2 +- .../desktop_mcp_oauth/renderer_lifecycle.mjs | 2 + hermes_cli/browser_runtime.py | 16 +++ hermes_cli/doctor_live.py | 6 +- hermes_cli/doctor_tools.py | 4 +- hermes_cli/setup_summary.py | 2 +- hermes_cli/tools_config_post_setup.py | 73 ++++++---- .../har-derived-api-client/SKILL.md | 2 +- .../scripts/har_capture.py | 2 +- plugins/google_meet/cli.py | 15 +- plugins/google_meet/meet_bot.py | 8 +- plugins/google_meet/tools.py | 2 +- pm/lock.json | 29 ---- pm/lock.py | 8 ++ pm/packages.py | 50 +++---- scripts/bundles/native.py | 22 +-- tests/docker/test_stage2_browser_discovery.py | 50 ++++--- tests/hermes_cli/test_browser_runtime.py | 66 +++++++++ tests/hermes_cli/test_setup_model_provider.py | 2 +- tests/hermes_cli/test_tools_config.py | 110 ++++++++------- tests/pm/test_chromium.py | 41 ++++++ tests/pm/test_pm_core.py | 3 +- tests/scripts/test_bundle_store_cleanup.py | 55 ++++++++ .../test_browser_chromium_autoinstall.py | 132 +++++------------- tests/tools/test_browser_chromium_check.py | 9 +- tests/tools/test_browser_headed_mode.py | 7 +- tests/tools/test_browser_open_timeout.py | 13 +- tools/browser_tool_install.py | 42 ++---- tools/browser_tool_lightpanda_fallback.py | 2 +- tools/browser_tool_session.py | 9 +- .../web-development-har-derived-api-client.md | 2 +- 42 files changed, 511 insertions(+), 351 deletions(-) create mode 100644 hermes_cli/browser_runtime.py create mode 100644 tests/hermes_cli/test_browser_runtime.py create mode 100644 tests/pm/test_chromium.py create mode 100644 tests/scripts/test_bundle_store_cleanup.py 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. From 949a508308106bc458a3d4f059d5b062385364a7 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 10 Sep 2026 14:21:15 -0400 Subject: [PATCH 2/2] fix(pm): remove download archives after package publication Retain archives through extraction, verification and publication so failed or paused installs can retry. After success, delete only the package's archives while holding the store lock. Normal installs commit facts first. Cross-target staging follows the same cleanup rule. Keep unrelated archives and resumable partials. Docker uses this PM behavior without a separate cleanup command. Later repairs may need a new download. Verified 64 focused PM tests and 9 Docker tests. Failure-injection tests cover extraction, verification, publication and facts writes. The rebuilt linux/amd64 image contains no archives in any layer and remains 1.073 GB compressed. Real Chromium navigation, clicks and screenshots pass. --- docs/shared-bundle-builds.md | 6 ++ pm/ensure.py | 10 ++ pm/store.py | 2 +- tests/docker/test_image_payload.py | 1 + tests/pm/test_download_cleanup.py | 123 ++++++++++++++++++++++ tests/pm/test_install_download_control.py | 8 ++ tests/pm/test_pm_core.py | 22 ++-- tests/pm/test_stage_only.py | 2 + 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 tests/pm/test_download_cleanup.py diff --git a/docs/shared-bundle-builds.md b/docs/shared-bundle-builds.md index 7cec3f69f4..a50aa14bae 100644 --- a/docs/shared-bundle-builds.md +++ b/docs/shared-bundle-builds.md @@ -85,6 +85,12 @@ 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. +PM retains completed download archives until the package is verified and +published. Normal installs commit package facts before deleting their archives. +Cross-target staging verifies the published entry before deleting its archives. +Failed or paused installs keep downloads for retry. Cleanup leaves unrelated +archives and resumable partials alone. A later repair may download again. + ## Verification boundary Tests execute shared snapshot/manifest helpers on real git fixtures, stage diff --git a/pm/ensure.py b/pm/ensure.py index 5d96697337..090e136f78 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -180,6 +180,12 @@ def _remove_entry(store: Store, entry_name: str) -> None: time.sleep(0.2 * (attempt + 1)) +def _remove_downloads(store: Store, artifacts: list[dict]) -> None: + """Release this package's archives after publication, under its store lock.""" + for artifact in artifacts: + _remove_entry(store, f"fetch-{artifact['sha256']}") + + def _entry_verified(package: Package, fact: dict, store: Store, target: str) -> bool: """Explicit installs re-check realized bytes; startup keeps its cheap facts check.""" entry = store.entry(fact["entry"]) @@ -247,6 +253,7 @@ def _install( if facts.installed( package.name, version, store.root, _identity(lockfile, package.name, target) ) and _entry_verified(package, facts.get(package.name), store, target): + _remove_downloads(store, artifacts) return if not artifacts: raise InstallError( @@ -308,6 +315,7 @@ def _install( raise if previous_entry.exists(): _remove_entry(store, previous_entry.name) + _remove_downloads(store, artifacts) except (InstallError, DownloadPaused): raise except Exception as e: @@ -367,6 +375,7 @@ def stage_only(name: str, target: str, progress=None) -> "Path": except OSError: recorded = None if not package.verify(entry, target) and recorded == pin: + _remove_downloads(store, artifacts) return entry if not artifacts: raise InstallError( @@ -406,6 +415,7 @@ def stage_only(name: str, target: str, progress=None) -> "Path": raise if previous_entry.exists(): _remove_entry(store, previous_entry.name) + _remove_downloads(store, artifacts) return store.entry(entry_name) diff --git a/pm/store.py b/pm/store.py index 7dba809be4..8fd8639097 100644 --- a/pm/store.py +++ b/pm/store.py @@ -261,7 +261,7 @@ def tree_digest(root: Path) -> str: class Store: """One directory of immutable published entries plus a scratch area. - Downloads are entries too, keyed by hash, so rebuilds never re-fetch.""" + Hash-keyed archives survive failed installs. Publication releases them.""" def __init__(self, root: Path): self.root = root diff --git a/tests/docker/test_image_payload.py b/tests/docker/test_image_payload.py index d876f6dd88..105e033820 100644 --- a/tests/docker/test_image_payload.py +++ b/tests/docker/test_image_payload.py @@ -13,6 +13,7 @@ from pm.registry import get_package from pm.store import current_target store = Path('/opt/hermes/tools') +assert not list(store.glob('fetch-*')), 'completed download archives must not ship' fact = Facts(store / 'facts.json').get('python') expected = get_package('python').binary(store / fact['entry'], current_target()) assert Path(sys._base_executable).resolve() == expected.resolve() diff --git a/tests/pm/test_download_cleanup.py b/tests/pm/test_download_cleanup.py new file mode 100644 index 0000000000..42e4e6f021 --- /dev/null +++ b/tests/pm/test_download_cleanup.py @@ -0,0 +1,123 @@ +"""Archive lifetime follows package publication, not transfer completion.""" +from __future__ import annotations + +import hashlib +import io +from pathlib import Path +import zipfile + +import pytest + +import pm +from pm import paths, registry +from pm.ensure import stage_only +from pm.lock import Facts, Lockfile +from pm.package import Package +from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 + + +class Components(Package): + name = "cleanup-components" + + def verify(self, entry, target): + return "" if (entry / "engine").is_file() and (entry / "library").is_file() else "incomplete package" + + +@pytest.fixture +def install_case(tmp_path, monkeypatch, dl_server): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + store = tmp_path / "store" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + lock_path = tmp_path / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path) + package = Components() + monkeypatch.setitem(registry._packages, package.name, package) + pins = [] + for name in ("engine", "library"): + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr(name, name.encode()) + path = f"/{name}.zip" + RangeHandler.payloads[path] = data.getvalue() + pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(data.getvalue()).hexdigest()}) + lock = Lockfile(lock_path) + lock.set_pin(package.name, "1", {pm.current_target(): pins}) + lock.save() + other = store / ("fetch-" + "f" * 64) + other.mkdir(parents=True) + (other / "other.zip").write_bytes(b"another install's download") + partial = paths.partials_root() / "unrelated.part" + partial.parent.mkdir(parents=True, exist_ok=True) + partial.write_bytes(b"in-progress download") + return package, store, pins, other, partial + + +def install(package, mode): + if mode == "stage": + return stage_only(package.name, pm.current_target()) + pm.ensure(package.name, explicit=True, base_env={}) + return pm.installed_package(package.name).path + + +@pytest.mark.parametrize("mode", ["install", "stage"]) +def test_publication_removes_only_its_archives(install_case, mode): + package, store, pins, other, partial = install_case + entry = install(package, mode) + for name in ("engine", "library"): + assert (entry / name).read_bytes() == name.encode() + assert not any((store / f"fetch-{pin['sha256']}").exists() for pin in pins) + assert (other / "other.zip").read_bytes() == b"another install's download" + assert partial.read_bytes() == b"in-progress download" + RangeHandler.payloads.clear() + assert install(package, mode) == entry # Already installed works offline without archives. + + +@pytest.mark.parametrize(("mode", "failure"), [ + (mode, failure) + for mode in ("install", "stage") + for failure in ("unpack", "verify", "publish", "published-verify", "facts") + if mode == "install" or failure != "facts" +]) +def test_failure_keeps_archives_for_offline_retry(install_case, monkeypatch, mode, failure): + package, store, pins, _, _ = install_case + original_verify = package.verify + original_unpack = package.unpack + + def fail(*args, **kwargs): + raise pm.InstallError(package.name, "injected failure") + + def unpack(archive, staged, target): + original_unpack(archive, staged, target) + if (staged / "library").exists(): + fail() + + def verify(entry, target): + published = entry.parent == store + if failure == "verify" or published: + fail() + return original_verify(entry, target) + + with monkeypatch.context() as fault: + if failure == "unpack": + fault.setattr(package, "unpack", unpack) + elif failure in ("verify", "published-verify"): + fault.setattr(package, "verify", verify) + elif failure == "publish": + original_publish = pm.Store.publish + + def publish(self, staged, name): + if name.startswith(package.name): + fail() + return original_publish(self, staged, name) + + fault.setattr(pm.Store, "publish", publish) + else: + fault.setattr(Facts, "record", fail) + with pytest.raises(pm.InstallError, match="injected failure"): + install(package, mode) + assert all((store / f"fetch-{pin['sha256']}").is_dir() for pin in pins) + RangeHandler.payloads.clear() + entry = install(package, mode) + assert (entry / "engine").read_bytes() == b"engine" + assert not any((store / f"fetch-{pin['sha256']}").exists() for pin in pins) diff --git a/tests/pm/test_install_download_control.py b/tests/pm/test_install_download_control.py index 60cf5e83eb..c6d9dbd570 100644 --- a/tests/pm/test_install_download_control.py +++ b/tests/pm/test_install_download_control.py @@ -6,6 +6,7 @@ import hashlib import io import threading import zipfile +from pathlib import Path import pytest @@ -29,6 +30,12 @@ def archive(files: dict[str, bytes]) -> bytes: return output.getvalue() +@pytest.fixture(autouse=True) +def isolate_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + + def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server): root = tmp_path / "store" lock_path = tmp_path / "lock.json" @@ -68,6 +75,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon assert (root / fact["entry"] / name).read_bytes() == body assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests assert not list(paths.partials_root().glob("*.part")) + assert not list(root.glob("fetch-*")) def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server): diff --git a/tests/pm/test_pm_core.py b/tests/pm/test_pm_core.py index 0bb83ead6a..c4e7e8c0c3 100644 --- a/tests/pm/test_pm_core.py +++ b/tests/pm/test_pm_core.py @@ -93,6 +93,8 @@ def served(tmp_path): def pm_env(tmp_path, served, monkeypatch): docroot, base_url = served runtime = tmp_path / "runtime" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) # Policy is pinned open here; the disabled-path tests pin it closed. @@ -161,10 +163,11 @@ def test_bad_hash_rejected(pm_env): def test_fetch_is_a_store_entry(pm_env): - from pm.ensure import ensure - - _, runtime, docroot, _ = pm_env - ensure("faketool", base_env={}) + lock_path, runtime, _, _ = pm_env + artifact = Lockfile(lock_path).artifacts("faketool", current_target())[0] + store = Store(runtime) + with store.scratch() as scratch: + store.fetch(artifact["url"], artifact["sha256"], scratch) fetches = [p for p in runtime.iterdir() if p.name.startswith("fetch-")] assert len(fetches) == 1 @@ -426,12 +429,15 @@ def test_gc_removes_fetch_cache_archives(pm_env): from pm.cli import cmd_gc from pm.ensure import ensure - _, runtime, *_ = pm_env + lock_path, runtime, *_ = pm_env ensure("faketool", base_env={}) - - # install() fetched via store.fetch → a fetch- archive cache dir. + # Downloads not consumed by a successful install remain eligible for GC. + artifact = Lockfile(lock_path).artifacts("faketool", current_target())[0] + store = Store(runtime) + with store.scratch() as scratch: + store.fetch(artifact["url"], artifact["sha256"], scratch) fetches = [p for p in runtime.iterdir() if p.name.startswith("fetch-")] - assert fetches, "install should have left fetch- download-cache dirs" + assert fetches cmd_gc(None) assert not [p for p in runtime.iterdir() if p.name.startswith("fetch-")], \ diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index 43b9acbb23..377b779c70 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -66,6 +66,8 @@ def _seed_fetch_cache(store: Store, payload: bytes) -> None: @pytest.fixture() def sandbox(tmp_path, monkeypatch): runtime = tmp_path / "runtime" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) store = Store(runtime) package = _FakePackage()