diff --git a/acp_adapter/bootstrap/__init__.py b/acp_adapter/bootstrap/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 new file mode 100644 index 00000000000..f840fd2d559 --- /dev/null +++ b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 @@ -0,0 +1,288 @@ +# bootstrap_browser_tools.ps1 — install agent-browser + Playwright Chromium +# into ~/.hermes/node/ for use by Hermes Agent's browser tools on Windows. +# +# Targets the registry-install path: users who got Hermes via +# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, +# so the install.ps1 `npm install`-in-repo flow doesn't apply. This script +# is a self-contained, idempotent slice of install.ps1's browser block. +# +# Usage: +# .\bootstrap_browser_tools.ps1 # use defaults +# .\bootstrap_browser_tools.ps1 -Yes # accept Chromium download +# .\bootstrap_browser_tools.ps1 -SkipChromium # Node + agent-browser only +# +# Idempotent: re-running this is safe and fast. + +[CmdletBinding()] +param( + [switch]$Yes, + [switch]$SkipChromium +) + +$ErrorActionPreference = "Stop" +$NodeVersion = "22" + +# ───────────────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────────────── + +function Write-Info { param([string]$msg) Write-Host "[*] $msg" -ForegroundColor Cyan } +function Write-Success { param([string]$msg) Write-Host "[+] $msg" -ForegroundColor Green } +function Write-Warn { param([string]$msg) Write-Host "[!] $msg" -ForegroundColor Yellow } +function Write-Err { param([string]$msg) Write-Host "[x] $msg" -ForegroundColor Red } + +# ───────────────────────────────────────────────────────────────────────── +# Paths +# ───────────────────────────────────────────────────────────────────────── + +$HermesHome = $env:HERMES_HOME +if (-not $HermesHome) { + $HermesHome = Join-Path $env:USERPROFILE ".hermes" +} +$NodePrefix = Join-Path $HermesHome "node" + +# ───────────────────────────────────────────────────────────────────────── +# Step 1: Node.js +# ───────────────────────────────────────────────────────────────────────── + +function Resolve-NpmExe { + # Same gotcha as install.ps1: prefer npm.cmd over npm.ps1 so the + # PowerShell execution policy doesn't block us. + $cmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $npmExe = $cmd.Source + if ($npmExe -like "*.ps1") { + $sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $sibling) { return $sibling } + } + return $npmExe +} + +function Resolve-NpxExe { + $cmd = Get-Command npx -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $npxExe = $cmd.Source + if ($npxExe -like "*.ps1") { + $sibling = Join-Path (Split-Path $npxExe -Parent) "npx.cmd" + if (Test-Path $sibling) { return $sibling } + } + return $npxExe +} + +function Ensure-Node { + # System Node on PATH? + $sysNode = Get-Command node -ErrorAction SilentlyContinue + if ($sysNode) { + try { + $v = & $sysNode.Source --version + $major = [int]($v -replace '^v(\d+).*', '$1') + if ($major -ge 20) { + Write-Success "Node.js $v found on PATH" + return + } + Write-Warn "Node.js $v is older than v20 — installing managed Node." + } catch { + Write-Warn "Failed to query Node version: $_" + } + } + + # Hermes-managed Node? + $managedNode = Join-Path $NodePrefix "node.exe" + if (Test-Path $managedNode) { + $v = & $managedNode --version + Write-Success "Node.js $v found (Hermes-managed at $NodePrefix)" + # Prepend to current-process PATH so subsequent npm/npx calls find it. + $env:PATH = "$NodePrefix;$env:PATH" + return + } + + Write-Info "Installing Node.js $NodeVersion LTS into $NodePrefix ..." + + $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } + $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" + + try { + $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing + $matches = [regex]::Matches($indexPage.Content, "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip") + if ($matches.Count -eq 0) { + Write-Err "Could not locate Node.js $NodeVersion zip for win-$arch" + throw "no tarball" + } + $zipName = $matches[0].Value + $zipUrl = "$indexUrl$zipName" + + $tmpDir = Join-Path $env:TEMP "hermes-node-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $zipPath = Join-Path $tmpDir $zipName + + Write-Info "Downloading $zipName ..." + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing + + Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force + $extracted = Get-ChildItem -Path $tmpDir -Directory | Where-Object { $_.Name -like "node-v*" } | Select-Object -First 1 + + if (-not $extracted) { Write-Err "Node.js extraction failed"; throw "extract" } + + if (Test-Path $NodePrefix) { Remove-Item -Recurse -Force $NodePrefix } + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + Move-Item -Path $extracted.FullName -Destination $NodePrefix + + Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue + + $env:PATH = "$NodePrefix;$env:PATH" + $v = & "$NodePrefix\node.exe" --version + Write-Success "Node.js $v installed to $NodePrefix" + } catch { + Write-Err "Node.js install failed: $_" + Write-Info "Install Node 20+ manually from https://nodejs.org/en/download/ and re-run." + throw + } +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 2: agent-browser +# ───────────────────────────────────────────────────────────────────────── + +function Ensure-AgentBrowser { + $npmExe = Resolve-NpmExe + if (-not $npmExe) { + Write-Err "npm not on PATH after Node install — aborting" + throw "npm missing" + } + + # Already installed? + $existing = Get-Command agent-browser -ErrorAction SilentlyContinue + if ($existing) { + Write-Success "agent-browser already installed at $($existing.Source)" + return + } + + # When the user has system Node (winget / installer-based), `npm install + # -g` writes to a directory that may require admin rights. Force the + # prefix to the user-writable Hermes-managed Node directory so we never + # need elevation and the agent can always find the result. Mirrors the + # bash bootstrap's `--prefix $NODE_PREFIX` strategy. + New-Item -ItemType Directory -Force -Path $NodePrefix | Out-Null + + Write-Info "Installing agent-browser (npm, prefix=$NodePrefix)..." + & $npmExe install -g --prefix $NodePrefix --silent ` + "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" + if ($LASTEXITCODE -ne 0) { + Write-Err "npm install -g agent-browser failed (exit $LASTEXITCODE)" + throw "npm install" + } + + # Windows npm global installs drop shims at $NodePrefix\ root (not bin/). + # Prepend to PATH so any subsequent npx call resolves them. + $env:PATH = "$NodePrefix;$env:PATH" + + Write-Success "agent-browser installed to $NodePrefix" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 3: Playwright Chromium +# ───────────────────────────────────────────────────────────────────────── + +function Find-SystemBrowser { + $candidates = @( + "C:\Program Files\Google\Chrome\Application\chrome.exe", + "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + "C:\Program Files\Chromium\Application\chromium.exe", + "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", + "${env:LOCALAPPDATA}\Chromium\Application\chromium.exe" + ) + foreach ($p in $candidates) { + if (Test-Path $p) { return $p } + } + # Edge — Chromium-based, agent-browser can use it + foreach ($p in @( + "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + "C:\Program Files\Microsoft\Edge\Application\msedge.exe" + )) { + if (Test-Path $p) { return $p } + } + return $null +} + +function Write-BrowserEnv { + param([string]$BrowserPath) + $envFile = Join-Path $HermesHome ".env" + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + if (Test-Path $envFile) { + $existing = Get-Content $envFile -Raw -ErrorAction SilentlyContinue + if ($existing -and ($existing -match "(?m)^AGENT_BROWSER_EXECUTABLE_PATH=")) { + return + } + } + Add-Content -Path $envFile -Value "" + Add-Content -Path $envFile -Value "# Hermes Agent browser tools — use the system Chrome/Chromium/Edge binary." + Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" + Write-Success "Configured browser tools to use $BrowserPath" +} + +function Confirm-ChromiumDownload { + if ($Yes) { return $true } + if (-not [Environment]::UserInteractive) { + Write-Warn "Non-interactive shell — skipping Chromium prompt." + Write-Info "Re-run with -Yes to install Chromium (~400 MB download)." + return $false + } + $reply = Read-Host "Install Playwright Chromium (~400 MB download)? [y/N]" + return ($reply -match "^(y|yes)$") +} + +function Ensure-Chromium { + if ($SkipChromium) { + Write-Info "Skipping Chromium install (-SkipChromium)" + return + } + + # agent-browser on Windows expects a Playwright-managed Chromium under + # %LOCALAPPDATA%\ms-playwright. The system-browser shortcut from the + # Linux/macOS path doesn't apply the same way on Windows — Playwright's + # default launch path won't pick up a stock Chrome install without an + # explicit AGENT_BROWSER_EXECUTABLE_PATH. We still offer it as a + # fallback when the user doesn't want the download. + + if (-not (Confirm-ChromiumDownload)) { + $sys = Find-SystemBrowser + if ($sys) { + Write-Info "Using system browser at $sys (Chromium download skipped)." + Write-BrowserEnv -BrowserPath $sys + } else { + Write-Info "Chromium install skipped. Browser tools won't launch until" + Write-Info "Chromium is installed or AGENT_BROWSER_EXECUTABLE_PATH is set." + } + return + } + + $npxExe = Resolve-NpxExe + if (-not $npxExe) { + Write-Err "npx not on PATH — cannot install Playwright Chromium" + throw "npx missing" + } + + Write-Info "Installing Playwright Chromium (~400 MB) ..." + & $npxExe --yes playwright install chromium + if ($LASTEXITCODE -ne 0) { + Write-Err "Playwright Chromium install failed (exit $LASTEXITCODE)" + Write-Info "Try again later: npx --yes playwright install chromium" + throw "playwright" + } + Write-Success "Playwright Chromium installed" +} + +# ───────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────── + +Write-Info "Hermes Agent: bootstrapping browser tools" +Write-Info " HERMES_HOME = $HermesHome" +Write-Info " OS = Windows" + +Ensure-Node +Ensure-AgentBrowser +Ensure-Chromium + +Write-Success "Browser tools setup complete." +Write-Info "Hermes Agent will pick up agent-browser from $NodePrefix on next launch." diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.sh b/acp_adapter/bootstrap/bootstrap_browser_tools.sh new file mode 100755 index 00000000000..9981069a6af --- /dev/null +++ b/acp_adapter/bootstrap/bootstrap_browser_tools.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# +# bootstrap_browser_tools.sh — install agent-browser + Playwright Chromium +# into ~/.hermes/node/ for use by Hermes Agent's browser tools. +# +# Targets the registry-install path: users who got Hermes via +# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, +# so the install.sh `npm install`-in-repo flow doesn't apply. This script +# is a self-contained, idempotent slice of install.sh's browser block — +# safe to run from `hermes-acp --setup-browser`, from a fresh terminal, +# or from install.sh itself (it's a no-op when everything is already in place). +# +# Usage: +# bootstrap_browser_tools.sh # use defaults +# bootstrap_browser_tools.sh --yes # accept the ~400MB Chromium download +# bootstrap_browser_tools.sh --skip-chromium # only install Node + agent-browser +# HERMES_HOME=/custom/path bootstrap_browser_tools.sh +# +# Idempotent: re-running this is safe and fast. Each step checks whether +# the work is already done. + +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────── +# Config +# ───────────────────────────────────────────────────────────────────────── + +NODE_VERSION="22" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +NODE_PREFIX="$HERMES_HOME/node" + +SKIP_CHROMIUM=false +ASSUME_YES=false + +# ───────────────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────────────── + +if [ -t 1 ]; then + C_GREEN='\033[0;32m' + C_YELLOW='\033[0;33m' + C_BLUE='\033[0;34m' + C_RED='\033[0;31m' + C_RESET='\033[0m' +else + C_GREEN='' ; C_YELLOW='' ; C_BLUE='' ; C_RED='' ; C_RESET='' +fi + +log_info() { printf "${C_BLUE}[*]${C_RESET} %s\n" "$*"; } +log_success() { printf "${C_GREEN}[✓]${C_RESET} %s\n" "$*"; } +log_warn() { printf "${C_YELLOW}[!]${C_RESET} %s\n" "$*" >&2; } +log_error() { printf "${C_RED}[✗]${C_RESET} %s\n" "$*" >&2; } + +# ───────────────────────────────────────────────────────────────────────── +# Arg parsing +# ───────────────────────────────────────────────────────────────────────── + +while [ $# -gt 0 ]; do + case "$1" in + --skip-chromium) SKIP_CHROMIUM=true ;; + --yes|-y) ASSUME_YES=true ;; + -h|--help) + cat </dev/null 2>&1; then + local found_ver major + found_ver=$(node --version 2>/dev/null) + major=$(echo "$found_ver" | sed -E 's/^v([0-9]+).*/\1/') + if [ -n "$major" ] && [ "$major" -ge 20 ]; then + log_success "Node.js $found_ver found on PATH" + return 0 + fi + log_warn "Node.js $found_ver is older than v20 — installing managed Node." + fi + + if [ -x "$NODE_PREFIX/bin/node" ]; then + local found_ver + found_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") + export PATH="$NODE_PREFIX/bin:$PATH" + log_success "Node.js $found_ver found (Hermes-managed at $NODE_PREFIX)" + return 0 + fi + + log_info "Installing Node.js $NODE_VERSION LTS into $NODE_PREFIX ..." + + local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" + local tarball_name + tarball_name=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.xz" \ + | head -1) + + if [ -z "$tarball_name" ]; then + tarball_name=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.gz" \ + | head -1) + fi + + if [ -z "$tarball_name" ]; then + log_error "Could not locate Node.js $NODE_VERSION tarball for $NODE_OS-$NODE_ARCH" + log_info "Install Node 20+ manually: https://nodejs.org/en/download/" + return 1 + fi + + local tmp_dir + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' RETURN + + log_info "Downloading $tarball_name ..." + if ! curl -fsSL "${index_url}${tarball_name}" -o "$tmp_dir/$tarball_name"; then + log_error "Node.js download failed" + return 1 + fi + + if [[ "$tarball_name" == *.tar.xz ]]; then + tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir" + else + tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" + fi + + local extracted_dir + extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1) + if [ ! -d "$extracted_dir" ]; then + log_error "Node.js extraction failed" + return 1 + fi + + mkdir -p "$HERMES_HOME" + rm -rf "$NODE_PREFIX" + mv "$extracted_dir" "$NODE_PREFIX" + + export PATH="$NODE_PREFIX/bin:$PATH" + + local installed_ver + installed_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") + log_success "Node.js $installed_ver installed to $NODE_PREFIX" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 2: agent-browser + @askjo/camofox-browser via global npm install +# ───────────────────────────────────────────────────────────────────────── + +ensure_agent_browser() { + if ! command -v npm >/dev/null 2>&1; then + log_error "npm not on PATH after Node install — aborting" + return 1 + fi + + # _find_agent_browser() in tools/browser_tool.py walks ~/.hermes/node/bin + # plus a few standard prefixes, so installing globally into the managed + # Node prefix is enough — no PATH manipulation needed from the agent side. + if [ -x "$NODE_PREFIX/bin/agent-browser" ] || command -v agent-browser >/dev/null 2>&1; then + log_success "agent-browser already installed" + return 0 + fi + + # When the system's `npm` resolves to a root-owned prefix (e.g. + # /usr/lib/node_modules), `npm install -g` fails with EACCES without + # sudo. Force the prefix to the user-writable Hermes-managed Node + # directory so we never need sudo and the agent can always find the + # result. If we installed Node ourselves above, this is a no-op + # (managed Node already uses $NODE_PREFIX). If the user has system + # Node, we still drop agent-browser under $NODE_PREFIX/bin/ — which + # is exactly where _browser_candidate_path_dirs() looks first. + mkdir -p "$NODE_PREFIX" + + log_info "Installing agent-browser (npm, prefix=$NODE_PREFIX)..." + if ! npm install -g --prefix "$NODE_PREFIX" --silent \ + agent-browser@^0.26.0 \ + "@askjo/camofox-browser@^1.5.2"; then + log_error "npm install -g agent-browser failed" + return 1 + fi + + # macOS/Linux global installs place the shim into $NODE_PREFIX/bin/. + # Add it to PATH for any subsequent steps (npx playwright). + export PATH="$NODE_PREFIX/bin:$PATH" + + log_success "agent-browser installed to $NODE_PREFIX/bin/" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 3: Playwright Chromium +# ───────────────────────────────────────────────────────────────────────── + +confirm_chromium_download() { + if [ "$ASSUME_YES" = true ]; then return 0; fi + if [ ! -t 0 ]; then + log_warn "Non-interactive shell — skipping Chromium prompt." + log_info "Re-run with --yes to install Chromium (~400 MB download)." + return 1 + fi + printf "Install Playwright Chromium (~400 MB download)? [y/N] " + local reply="" + read -r reply || reply="" + case "$reply" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +# Detect a usable system Chrome/Chromium. agent-browser's Chrome engine can +# use it instead of downloading Playwright's bundled Chromium, saving the +# download cost. Returns the path or empty string. +find_system_browser() { + local candidate + for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + # macOS app-bundle locations + if [ "$OS" = "macos" ]; then + for candidate in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" ; do + if [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + fi + return 1 +} + +write_browser_env() { + local browser_path="$1" + local env_file="$HERMES_HOME/.env" + mkdir -p "$HERMES_HOME" + if [ -f "$env_file" ] && grep -q "^AGENT_BROWSER_EXECUTABLE_PATH=" "$env_file"; then + return 0 + fi + { + echo "" + echo "# Hermes Agent browser tools — use the system Chrome/Chromium binary." + echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" + } >> "$env_file" + log_success "Configured browser tools to use $browser_path" +} + +ensure_chromium() { + if [ "$SKIP_CHROMIUM" = true ]; then + log_info "Skipping Chromium install (--skip-chromium)" + return 0 + fi + + local system_browser + system_browser="$(find_system_browser 2>/dev/null || true)" + if [ -n "$system_browser" ]; then + log_success "Found system browser: $system_browser" + log_info "Skipping Playwright Chromium download; agent-browser will use it." + write_browser_env "$system_browser" + return 0 + fi + + if ! confirm_chromium_download; then + log_info "Chromium install skipped. Browser tools will only work if you" + log_info "set AGENT_BROWSER_EXECUTABLE_PATH or install Chromium later." + return 0 + fi + + if ! command -v npx >/dev/null 2>&1; then + log_error "npx not on PATH — cannot install Playwright Chromium" + return 1 + fi + + log_info "Installing Playwright Chromium (~400 MB) ..." + + # On apt-based distros, --with-deps requires sudo. Try non-interactively + # only — never prompt — and fall back to the bare browser-only install. + local installed=false + if [ "$OS" = "linux" ]; then + case "$DISTRO" in + ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) + if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then + log_info "Installing system deps with --with-deps (sudo available)" + if npx --yes playwright install --with-deps chromium; then + installed=true + fi + else + log_warn "sudo not available non-interactively — installing Chromium without system deps." + log_info "If browser tools fail to launch, an administrator should run:" + log_info " sudo npx playwright install-deps chromium" + fi + ;; + arch|manjaro|cachyos|endeavouros|garuda) + log_info "Arch-family system dependencies are not auto-installed." + log_info "If launch fails, run: sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + ;; + fedora|rhel|centos|rocky|alma) + log_info "Fedora/RHEL system dependencies are not auto-installed." + log_info "If launch fails, run: sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" + ;; + opensuse*|sles) + log_info "openSUSE system dependencies are not auto-installed." + ;; + esac + fi + + if [ "$installed" = false ]; then + if npx --yes playwright install chromium; then + installed=true + fi + fi + + if [ "$installed" = true ]; then + log_success "Playwright Chromium installed" + else + log_error "Playwright Chromium install failed" + log_info "Try again later: npx --yes playwright install chromium" + return 1 + fi +} + +# ───────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────── + +main() { + log_info "Hermes Agent: bootstrapping browser tools" + log_info " HERMES_HOME = $HERMES_HOME" + log_info " OS / arch = $NODE_OS-$NODE_ARCH ${DISTRO:+($DISTRO)}" + + ensure_node + ensure_agent_browser + ensure_chromium + + log_success "Browser tools setup complete." + log_info "Hermes Agent will pick up agent-browser from $NODE_PREFIX/bin/ on next launch." +} + +main diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 48e677a6522..cf5c2ba9cfb 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -124,6 +124,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Run interactive Hermes provider/model setup for ACP terminal auth", ) + parser.add_argument( + "--setup-browser", + action="store_true", + help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " + "for browser tool support. Idempotent.", + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + dest="assume_yes", + help="Accept all prompts (currently used by --setup-browser to skip the " + "~400 MB Chromium download confirmation).", + ) return parser.parse_args(argv) @@ -150,6 +164,75 @@ def _run_setup() -> None: finally: sys.argv = old_argv + # Offer browser-tools install as a follow-up. The terminal auth method + # is the one supported first-run UX for registry installs, so this is + # the natural moment to ask. Skip silently if stdin isn't a TTY (the + # answer can't be collected anyway). + if not sys.stdin.isatty(): + return + try: + reply = input( + "\nInstall browser tools? Downloads agent-browser (npm) and " + "optionally Playwright Chromium (~400 MB). [y/N] " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + return + if reply in {"y", "yes"}: + _run_setup_browser(assume_yes=False) + + +def _run_setup_browser(assume_yes: bool = False) -> int: + """Bootstrap agent-browser + Playwright Chromium for the registry-install path. + + Shells out to the bundled platform-specific bootstrap script + (acp_adapter/bootstrap/bootstrap_browser_tools.{sh,ps1}) so the install + logic lives in one place — readable, debuggable, and shareable with + install.sh / install.ps1 if we ever want to call it from there too. + + Returns the script's exit code (0 on success). + """ + import platform + import subprocess + + bootstrap_dir = Path(__file__).resolve().parent / "bootstrap" + + if platform.system() == "Windows": + script = bootstrap_dir / "bootstrap_browser_tools.ps1" + if not script.is_file(): + print( + f"Bootstrap script not found at {script} — wheel may be incomplete.", + file=sys.stderr, + ) + return 1 + cmd = [ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", str(script), + ] + if assume_yes: + cmd.append("-Yes") + else: + script = bootstrap_dir / "bootstrap_browser_tools.sh" + if not script.is_file(): + print( + f"Bootstrap script not found at {script} — wheel may be incomplete.", + file=sys.stderr, + ) + return 1 + cmd = ["bash", str(script)] + if assume_yes: + cmd.append("--yes") + + # stdio is inherited so the user sees the bootstrap's progress live. + try: + result = subprocess.run(cmd, check=False) + except FileNotFoundError as exc: + # bash / powershell.exe not on PATH + print(f"Could not launch browser bootstrap: {exc}", file=sys.stderr) + return 1 + return result.returncode + def main(argv: list[str] | None = None) -> None: """Entry point: load env, configure logging, run the ACP agent.""" @@ -163,6 +246,11 @@ def main(argv: list[str] | None = None) -> None: if args.setup: _run_setup() return + if args.setup_browser: + rc = _run_setup_browser(assume_yes=args.assume_yes) + if rc != 0: + sys.exit(rc) + return _setup_logging() _load_env() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6b770edaf28..833172a23b9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11715,6 +11715,20 @@ Examples: action="store_true", help="Run interactive Hermes provider/model setup for ACP terminal auth", ) + acp_parser.add_argument( + "--setup-browser", + action="store_true", + help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " + "for browser tool support (idempotent).", + ) + acp_parser.add_argument( + "--yes", + "-y", + action="store_true", + dest="assume_yes", + help="Accept all prompts (used by --setup-browser to skip the " + "~400 MB Chromium download confirmation).", + ) def cmd_acp(args): """Launch Hermes Agent as an ACP server.""" @@ -11728,6 +11742,10 @@ Examples: acp_argv.append("--check") if getattr(args, "setup", False): acp_argv.append("--setup") + if getattr(args, "setup_browser", False): + acp_argv.append("--setup-browser") + if getattr(args, "assume_yes", False): + acp_argv.append("--yes") acp_main(acp_argv) except ImportError: print("ACP dependencies not installed.", file=sys.stderr) diff --git a/pyproject.toml b/pyproject.toml index 20fecac228e..ae2fff385a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,9 +212,10 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*"] gateway = ["assets/**/*"] +acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"] [tool.setuptools.packages.find] -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "acp_adapter.*", "plugins", "plugins.*", "providers", "providers.*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 4c7e55f1d4b..81d30cd868c 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -1,6 +1,9 @@ """Tests for acp_adapter.entry startup wiring.""" +import sys + import acp +import pytest from acp_adapter import entry @@ -42,12 +45,152 @@ def test_main_setup_runs_model_configuration(monkeypatch): calls = {} def fake_hermes_main(): - import sys - calls["argv"] = sys.argv[:] monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main) + # Pretend stdin is not a TTY so the follow-up browser prompt is skipped. + # That keeps this test focused on the model-setup wiring; the + # browser-prompt path has its own test below. + monkeypatch.setattr("sys.stdin.isatty", lambda: False) entry.main(["--setup"]) assert calls["argv"][1:] == ["model"] + + +def test_main_setup_offers_browser_install_when_tty(monkeypatch): + """When stdin is a TTY and the user answers yes, model setup is followed + by a browser-tools bootstrap call.""" + monkeypatch.setattr("hermes_cli.main.main", lambda: None) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "y") + + bootstrap_calls = [] + monkeypatch.setattr( + entry, + "_run_setup_browser", + lambda assume_yes=False: bootstrap_calls.append(assume_yes) or 0, + ) + + entry.main(["--setup"]) + + assert bootstrap_calls == [False] + + +def test_main_setup_skips_browser_prompt_on_no(monkeypatch): + monkeypatch.setattr("hermes_cli.main.main", lambda: None) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "") + + called = [] + monkeypatch.setattr( + entry, + "_run_setup_browser", + lambda assume_yes=False: called.append(assume_yes) or 0, + ) + + entry.main(["--setup"]) + + assert called == [] + + +def test_main_setup_browser_invokes_bundled_script(monkeypatch): + """`hermes-acp --setup-browser` must shell out to the bundled bootstrap + script — never reimplement the install logic inline.""" + monkeypatch.setattr("platform.system", lambda: "Linux") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser"]) + + assert captured["cmd"][0] == "bash" + assert captured["cmd"][1].endswith("bootstrap_browser_tools.sh") + # --yes is NOT passed when the flag is absent. + assert "--yes" not in captured["cmd"] + + +def test_main_setup_browser_forwards_yes_flag(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser", "--yes"]) + + assert "--yes" in captured["cmd"] + + +def test_main_setup_browser_uses_powershell_on_windows(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser", "--yes"]) + + assert captured["cmd"][0] == "powershell.exe" + assert any(part.endswith("bootstrap_browser_tools.ps1") for part in captured["cmd"]) + assert "-Yes" in captured["cmd"] + + +def test_main_setup_browser_propagates_failure(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + + class _R: + returncode = 7 + + monkeypatch.setattr("subprocess.run", lambda cmd, check=False: _R()) + + with pytest.raises(SystemExit) as excinfo: + entry.main(["--setup-browser"]) + assert excinfo.value.code == 7 + + +def test_bootstrap_scripts_ship_with_package(): + """The package-data wiring (pyproject.toml) must include the bootstrap + scripts — otherwise `--setup-browser` 404s at runtime.""" + from pathlib import Path + + bootstrap_dir = Path(entry.__file__).resolve().parent / "bootstrap" + sh = bootstrap_dir / "bootstrap_browser_tools.sh" + ps1 = bootstrap_dir / "bootstrap_browser_tools.ps1" + + assert sh.is_file(), f"missing bundled script: {sh}" + assert ps1.is_file(), f"missing bundled script: {ps1}" + + sh_text = sh.read_text(encoding="utf-8") + ps1_text = ps1.read_text(encoding="utf-8") + + # Sanity: scripts know how to find the Hermes-managed Node prefix. + assert "HERMES_HOME" in sh_text + assert "agent-browser" in sh_text + assert "HermesHome" in ps1_text + assert "agent-browser" in ps1_text diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 92a755c9ada..6540748c889 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -78,6 +78,27 @@ hermes acp --version hermes acp --check ``` +### Browser tools (optional) + +Browser tools (`browser_navigate`, `browser_click`, etc.) depend on the +`agent-browser` npm package and Chromium, which aren't part of the Python +wheel. Install them with: + +```bash +hermes acp --setup-browser # interactive (prompts before ~400 MB download) +hermes acp --setup-browser --yes # accept the download non-interactively +``` + +This is the standalone command. The Zed registry's terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly. + +What it does: + +- Installs Node.js 22 LTS into `~/.hermes/node/` if missing +- `npm install -g agent-browser @askjo/camofox-browser` into that prefix (no sudo needed — `npm`'s `--prefix` points at the user-writable Hermes-managed Node) +- Installs Playwright Chromium, or uses a detected system Chrome/Chromium when available + +The bootstrap is idempotent — re-running it is fast and skips work that's already done. + ## Editor setup ### VS Code