Merge branch 'ethie/pm-clean' of github.com:NousResearch/hermes-agent into ethie/pm-clean
This commit is contained in:
@@ -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; \
|
||||
|
||||
@@ -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-<rev>, chromium_headless_shell-<rev>, 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-<rev> 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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,18 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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}});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
16
hermes_cli/browser_runtime.py
Normal file
16
hermes_cli/browser_runtime.py
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
pm/ensure.py
10
pm/ensure.py
@@ -181,6 +181,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"])
|
||||
@@ -250,6 +256,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(
|
||||
@@ -322,6 +329,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:
|
||||
@@ -381,6 +389,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(
|
||||
@@ -420,6 +429,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)
|
||||
|
||||
|
||||
|
||||
29
pm/lock.json
29
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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
`<name with '-'→'_'>-<revision>`, 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<sha> 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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
66
tests/hermes_cli/test_browser_runtime.py
Normal file
66
tests/hermes_cli/test_browser_runtime.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
41
tests/pm/test_chromium.py
Normal file
41
tests/pm/test_chromium.py
Normal file
@@ -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)
|
||||
123
tests/pm/test_download_cleanup.py
Normal file
123
tests/pm/test_download_cleanup.py
Normal file
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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-<sha> 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-<sha> download-cache dirs"
|
||||
assert fetches
|
||||
|
||||
cmd_gc(None)
|
||||
assert not [p for p in runtime.iterdir() if p.name.startswith("fetch-")], \
|
||||
@@ -667,7 +673,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 +681,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
55
tests/scripts/test_bundle_store_cleanup.py
Normal file
55
tests/scripts/test_bundle_store_cleanup.py
Normal file
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user