Merge branch 'ethie/pm-clean' of github.com:NousResearch/hermes-agent into ethie/pm-clean

This commit is contained in:
ethernet
2026-09-10 16:09:14 -04:00
48 changed files with 676 additions and 360 deletions

View File

@@ -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; \

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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))
}

View File

@@ -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, {

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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'))

View File

@@ -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 = []

View File

@@ -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}});

View File

@@ -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));

View File

@@ -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 } : {})

View 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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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}

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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
View 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)

View 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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View 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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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:

View File

@@ -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.