Merge branch 'simp/r3-31-A' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 22:41:07 -07:00
6 changed files with 910 additions and 1384 deletions
+401 -676
View File
File diff suppressed because it is too large Load Diff
+187 -293
View File
@@ -1,9 +1,8 @@
"""Browser session lifecycle: inactivity janitor, orphan reaper, per-session teardown, atexit emergency cleanup.
Split out of ``tools/browser_tool.py``; every name is re-imported there so
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
Split out of ``tools/browser_tool.py``; every name is re-imported there. Origin
symbols and module state are read/written through ``_bt`` (the origin module,
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
the :data:`tools.browser_tool_origin.origin` proxy) so
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
"""
@@ -19,17 +18,12 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
from tools.browser_tool_origin import origin_module as _origin
from tools.browser_tool_origin import origin as _bt
def _session_expiry_timestamp(session_info: Dict[str, Any]) -> Optional[float]:
"""Return a provider-authoritative session expiry as epoch seconds.
Cloud providers may omit ``expires_at``. Unknown or malformed values are
therefore treated as having no known expiry, preserving the existing
lifecycle for local browsers and providers without an expiry contract.
"""
_bt = _origin()
"""Provider-authoritative session expiry as epoch seconds; None when absent or
malformed (cloud providers may omit ``expires_at``; local browsers never have one)."""
value = session_info.get("expires_at")
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
@@ -52,8 +46,7 @@ def _session_expiry_timestamp(session_info: Dict[str, Any]) -> Optional[float]:
def _session_has_expired(
session_info: Dict[str, Any], *, now: Optional[float] = None
) -> bool:
"""Return whether a cached browser session crossed its provider deadline."""
_bt = _origin()
"""Whether a cached browser session crossed its provider deadline."""
expires_at = _bt._session_expiry_timestamp(session_info)
if expires_at is None:
return False
@@ -61,23 +54,16 @@ def _session_has_expired(
def _emergency_cleanup_all_sessions():
"""
Emergency cleanup of all active browser sessions.
Called on process exit or interrupt to prevent orphaned sessions.
Also runs the orphan reaper to clean up daemons left behind by previously
crashed hermes processes — this way every clean hermes exit sweeps
accumulated orphans, not just ones that actively used the browser tool.
"""
_bt = _origin()
"""atexit: close this process's sessions, then sweep orphans left by crashed
hermes processes — every clean exit reaps accumulated orphans, not only
processes that used the browser tool."""
if _bt._cleanup_done:
return
_bt._cleanup_done = True
# Clean up this process's own sessions first, so their owner_pid files
# are removed before the reaper scans.
# Real-profile Chrome processes are launched directly (not by
# agent-browser), so the session cleanup below never reaps them.
# Own sessions first so their owner_pid files are gone before the reaper scans.
# Real-profile Chrome is launched directly (not by agent-browser), so the
# session cleanup never reaps it.
try:
_bt._terminate_real_profile_chrome()
except Exception as e:
@@ -97,9 +83,7 @@ def _emergency_cleanup_all_sessions():
_bt._cleanup_failures.clear()
_bt._recording_sessions.clear()
# Lightpanda servers (Browser Use mode) are processes we spawned; the
# session cleanup above stops the tracked ones, this catches any that
# fell out of ``_active_sessions``.
# Lightpanda servers we spawned that fell out of ``_active_sessions``.
try:
from tools.browser_lightpanda import stop_all_lightpanda
@@ -107,8 +91,7 @@ def _emergency_cleanup_all_sessions():
except Exception as e:
_bt.logger.debug("Lightpanda cleanup on exit failed: %s", e)
# Sweep orphans from other crashed hermes processes. Safe even if we
# never used the browser — uses owner_pid liveness to avoid reaping
# Safe even if we never used the browser — owner_pid liveness protects
# daemons owned by other live hermes processes.
try:
_bt._reap_orphaned_browser_sessions()
@@ -120,11 +103,10 @@ def _emergency_cleanup_all_sessions():
def _session_owner_scope(task_id: str):
"""Run under the Hermes home + secret scope owning ``task_id``'s session (no-op if unrecorded).
The janitor thread is process-global, so each teardown must re-enter its
OWN profile's scope rather than inherit the spawning profile's; never falls
The janitor thread is process-global, so each teardown must re-enter its OWN
profile's scope rather than inherit the spawning profile's; never falls
through to ``os.environ``.
"""
_bt = _origin()
owner_home = _bt._session_owner_homes.get(task_id)
if owner_home is None:
yield
@@ -147,22 +129,31 @@ def _session_owner_scope(task_id: str):
reset_hermes_home_override(home_token)
def _cleanup_inactive_browser_sessions():
"""Close sessions inactive longer than the timeout (called by the cleanup thread).
def _forget_session_tracking(task_id: str, *, activity: bool = True, session: bool = False) -> None:
"""Drop the janitor's bookkeeping (and optionally the session entry) for ``task_id``."""
with _bt._cleanup_lock:
if session:
_bt._active_sessions.pop(task_id, None)
if activity:
_bt._session_last_activity.pop(task_id, None)
_bt._session_owner_homes.pop(task_id, None)
_bt._cleanup_failures.pop(task_id, None)
Each session is torn down under its owner profile's scope. A session whose
cleanup keeps failing is force-reaped after MAX_INACTIVITY_CLEANUP_FAILURES
attempts instead of retrying forever; only a successful cleanup clears its
failure count.
def _cleanup_inactive_browser_sessions():
"""Close sessions inactive longer than the timeout (cleanup thread).
Each teardown runs under its owner profile's scope. A session whose cleanup
keeps failing is force-reaped after MAX_INACTIVITY_CLEANUP_FAILURES attempts;
only a successful cleanup clears its failure count.
"""
_bt = _origin()
current_time = time.time()
sessions_to_cleanup = []
with _bt._cleanup_lock:
for task_id, last_time in list(_bt._session_last_activity.items()):
if current_time - last_time > _bt.BROWSER_SESSION_INACTIVITY_TIMEOUT:
sessions_to_cleanup.append(task_id)
sessions_to_cleanup = [
task_id for task_id, last_time in list(_bt._session_last_activity.items())
if current_time - last_time > _bt.BROWSER_SESSION_INACTIVITY_TIMEOUT
]
for task_id in sessions_to_cleanup:
elapsed = int(current_time - _bt._session_last_activity.get(task_id, current_time))
@@ -170,10 +161,7 @@ def _cleanup_inactive_browser_sessions():
try:
with _bt._session_owner_scope(task_id):
_bt.cleanup_browser(task_id)
with _bt._cleanup_lock:
_bt._session_last_activity.pop(task_id, None)
_bt._session_owner_homes.pop(task_id, None)
_bt._cleanup_failures.pop(task_id, None)
_forget_session_tracking(task_id)
except Exception as e:
with _bt._cleanup_lock:
failures = _bt._cleanup_failures[task_id] = _bt._cleanup_failures.get(task_id, 0) + 1
@@ -189,21 +177,13 @@ def _cleanup_inactive_browser_sessions():
except Exception as reap_exc:
_bt.logger.error("Force-reap of browser session %s failed: %s", task_id, reap_exc)
finally:
with _bt._cleanup_lock:
_bt._session_owner_homes.pop(task_id, None)
_bt._cleanup_failures.pop(task_id, None)
_forget_session_tracking(task_id, activity=False)
def _write_owner_pid(socket_dir: str, session_name: str) -> None:
"""Record the current hermes PID as the owner of a browser socket dir.
Written atomically to ``<socket_dir>/<session_name>.owner_pid`` so the
orphan reaper can distinguish daemons owned by a live hermes process
(don't reap) from daemons whose owner crashed (reap). Best-effort —
an OSError here just falls back to the legacy ``tracked_names``
heuristic in the reaper.
"""
_bt = _origin()
"""Record this hermes PID in ``<socket_dir>/<session>.owner_pid`` so the orphan
reaper can tell live-owner daemons from crashed-owner ones. Best-effort: an
OSError falls back to the legacy ``tracked_names`` heuristic."""
try:
path = os.path.join(socket_dir, f"{session_name}.owner_pid")
with open(path, "w", encoding="utf-8") as f:
@@ -217,18 +197,13 @@ def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,
session_name: str) -> bool:
"""Confirm a live PID is genuinely *this* session's agent-browser daemon.
The ``.pid`` file lives in a world-writable, predictably-named temp dir and
is written by the daemon, not us: a same-user actor can plant one pointing
at a victim PID, or a recycled PID can land on an unrelated process — and
reaping is a *tree* kill, i.e. an arbitrary-process DoS. Two psutil checks
must both pass: (1) identity — ``agent-browser`` in the name or cmdline;
(2) binding — the socket dir path/basename in the cmdline, or
``AGENT_BROWSER_SOCKET_DIR`` in its environ. (2) is the real spoof defense:
an attacker would need a real daemon embedding this exact path, which they
could already signal. Fail-closed on any ambiguity (unreadable cmdline, no
match): refuse to reap and leave process and socket dir alone.
The ``.pid`` file lives in a world-writable temp dir and is written by the
daemon: a same-user actor can plant one pointing at a victim PID, or a recycled
PID can land on an unrelated process — and reaping is a *tree* kill. Two
checks must pass: (1) identity — ``agent-browser`` in name or cmdline;
(2) binding — the socket dir in the cmdline or ``AGENT_BROWSER_SOCKET_DIR`` in
its environ (the real spoof defense). Fail-closed on any ambiguity.
"""
_bt = _origin()
try:
import psutil
except ImportError: # psutil is a hard dep; defensive only
@@ -243,8 +218,7 @@ def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,
name = (proc.name() or "").lower()
cmdline = " ".join(proc.cmdline() or []).lower()
except psutil.NoSuchProcess:
# Vanished between the liveness check and now — nothing to reap.
return False
return False # vanished between the liveness check and now
except (psutil.AccessDenied, OSError) as exc:
_bt.logger.warning(
"Refusing to reap browser daemon PID %d (session %s): "
@@ -252,14 +226,12 @@ def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,
daemon_pid, session_name, exc)
return False
looks_like_browser = "agent-browser" in name or "agent-browser" in cmdline
if not looks_like_browser:
if "agent-browser" not in name and "agent-browser" not in cmdline:
_bt.logger.warning(
"Refusing to reap PID %d (session %s): not an agent-browser "
"process (name=%r)", daemon_pid, session_name, name)
return False
# Binding check: the live process must reference *this* socket dir.
socket_dir_l = socket_dir.lower()
socket_base_l = os.path.basename(socket_dir).lower()
bound = socket_dir_l in cmdline or (
@@ -271,9 +243,7 @@ def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,
bound = bool(env_dir) and os.path.normpath(env_dir) == \
os.path.normpath(socket_dir)
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
# environ() can be denied even same-user on some platforms.
# cmdline already failed to bind — fail closed.
bound = False
bound = False # environ() can be denied even same-user; cmdline already failed — fail closed
if not bound:
_bt.logger.warning(
@@ -288,10 +258,9 @@ def _verify_reapable_browser_daemon(daemon_pid: int, socket_dir: str,
def _socket_dir_idle_seconds(socket_dir: str) -> Optional[float]:
"""Seconds since anything in ``socket_dir`` was last written; None if unknown (fail safe).
Every command writes ``_stdout_<cmd>`` / ``_stderr_<cmd>`` there, so the
newest mtime is a last-activity marker that survives hermes restarts and
lost in-memory bookkeeping. The dir's own mtime is not enough — rewriting
an existing ``_stdout_click`` doesn't touch it — so entries are scanned too.
Every command writes ``_stdout_<cmd>`` there, so the newest mtime is a
last-activity marker surviving hermes restarts. The dir's own mtime is not
enough — rewriting an existing file doesn't touch it — so entries are scanned.
"""
try:
latest = os.path.getmtime(socket_dir)
@@ -311,34 +280,47 @@ def _socket_dir_idle_seconds(socket_dir: str) -> Optional[float]:
return max(0.0, time.time() - latest)
def _read_pid_file(path: str) -> Optional[int]:
"""Integer PID from ``path``; None when missing or corrupt."""
try:
return int(Path(path).read_text(encoding="utf-8").strip())
except (ValueError, OSError):
return None
def _owner_pid_alive(socket_dir: str, session_name: str) -> Tuple[Optional[int], Optional[bool]]:
"""Read ``<session>.owner_pid`` and report ``(pid, alive)``; ``(None, None)`` when missing/corrupt."""
owner_pid_file = os.path.join(socket_dir, f"{session_name}.owner_pid")
if not os.path.isfile(owner_pid_file):
owner_pid = _read_pid_file(os.path.join(socket_dir, f"{session_name}.owner_pid"))
if owner_pid is None:
return None, None
try:
owner_pid = int(Path(owner_pid_file).read_text(encoding="utf-8").strip())
# ``os.kill(pid, 0)`` is NOT a no-op on Windows; use the cross-platform check.
from gateway.status import _pid_exists
return owner_pid, _pid_exists(owner_pid)
except (ValueError, OSError):
return None, None # corrupt file — fall through to legacy handling
# ``os.kill(pid, 0)`` is NOT a no-op on Windows; use the cross-platform check.
from gateway.status import _pid_exists
return owner_pid, _pid_exists(owner_pid)
def _terminate_verified_daemon(daemon_pid: int) -> bool:
"""Tree-kill ``daemon_pid`` if it has a start-time fingerprint (so a PID swapped
between check and kill is refused); False when no fingerprint. Raises on OS errors."""
from gateway.status import get_process_start_time
from tools.process_registry import ProcessRegistry
daemon_start = get_process_start_time(daemon_pid)
if daemon_start is None:
return False
ProcessRegistry._terminate_host_pid(daemon_pid, daemon_start)
return True
def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) -> bool:
"""Reap one ``agent-browser-<session>`` dir if orphaned; return True when a daemon was killed.
"""Reap one ``agent-browser-<session>`` dir if orphaned; True when a daemon was killed.
Ownership priority: (1) a live ``owner_pid`` means another hermes process
owns it — leave it alone UNLESS it is untracked here and idle past
Ownership priority: (1) a live ``owner_pid`` means another hermes process owns
it — leave it alone UNLESS it is untracked here and idle past
``BROWSER_ORPHAN_GRACE_SECONDS`` (owner-alive alone made leaked daemons
immortal: in-memory tracking is lost on any exception path and the daemon's
own idle timeout doesn't fire when it is wedged); (2) no owner_pid (legacy)
falls back to this process's tracking. A pidless dir is only stale after the
grace period — deleting it immediately races the creator's first stdout open.
The daemon PID is verified as ours before a tree-kill (world-writable dir,
recycled PIDs), and refused without a start-time fingerprint.
immortal); (2) no owner_pid (legacy) falls back to this process's tracking.
A pidless dir is only stale after the grace period — deleting it immediately
races the creator's first stdout open. The daemon PID is identity-verified
before a tree-kill and refused without a start-time fingerprint.
"""
_bt = _origin()
owner_pid, owner_alive = _bt._owner_pid_alive(socket_dir, session_name)
if owner_alive is True:
if session_name in tracked_names:
@@ -362,32 +344,23 @@ def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) ->
shutil.rmtree(socket_dir, ignore_errors=True)
return False
try:
daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip())
except (ValueError, OSError):
shutil.rmtree(socket_dir, ignore_errors=True)
return False
daemon_pid = _read_pid_file(pid_file)
from gateway.status import _pid_exists
if not _pid_exists(daemon_pid):
if daemon_pid is None or not _pid_exists(daemon_pid):
shutil.rmtree(socket_dir, ignore_errors=True)
return False
if not _bt._verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
return False # leave process and dir for a later sweep once the imposter PID is gone
# Tree-kill so Chromium children (renderer, GPU, ...) go too, not just the daemon.
# Tree-kill so Chromium children (renderer, GPU, ...) go too.
reaped = False
try:
from gateway.status import get_process_start_time
from tools.process_registry import ProcessRegistry
daemon_start = get_process_start_time(daemon_pid)
if daemon_start is None:
if not _terminate_verified_daemon(daemon_pid):
_bt.logger.warning(
"Refusing to reap browser daemon PID %d (session %s): "
"no start-time fingerprint available", daemon_pid, session_name)
return False
ProcessRegistry._terminate_host_pid(daemon_pid, daemon_start)
_bt.logger.info("Reaped orphaned browser daemon PID %d (session %s)",
daemon_pid, session_name)
reaped = True
@@ -400,20 +373,15 @@ def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) ->
def _reap_orphaned_browser_sessions():
"""Kill agent-browser daemons whose owning hermes process is gone.
When the process that created a session exits uncleanly (SIGKILL, crash,
gateway restart) the in-memory ``_active_sessions`` tracking is lost but the
node + Chromium processes keep running. Scans the tmp dir for
``agent-browser-*`` socket dirs and applies ``_reap_socket_dir``'s ownership
rules (owner_pid file first — cross-process safe, two hermes instances never
reap each other — then in-process tracking for legacy daemons).
Safe to call from any context — atexit, cleanup thread, or on demand.
An unclean exit (SIGKILL, crash, gateway restart) loses ``_active_sessions``
but node + Chromium keep running. Scans the tmp dir for ``agent-browser-*``
socket dirs and applies ``_reap_socket_dir``'s ownership rules. Safe from any
context — atexit, cleanup thread, or on demand.
"""
_bt = _origin()
import glob
# Lightpanda servers (Browser Use mode) keep their own records (no
# agent-browser socket dir); sweep them with the same owner-liveness rule
# BEFORE the daemon scan, which may return early.
# Lightpanda servers keep their own records (no socket dir); sweep them with the
# same owner-liveness rule BEFORE the daemon scan, which may return early.
try:
from tools.browser_lightpanda import reap_orphaned_lightpanda
@@ -446,19 +414,13 @@ def _reap_orphaned_browser_sessions():
def _browser_cleanup_thread_worker():
"""Every 30s: close sessions idle past BROWSER_SESSION_INACTIVITY_TIMEOUT.
Also reaps orphaned daemons on startup AND every BROWSER_ORPHAN_REAP_INTERVAL
seconds — a daemon can fall out of in-memory tracking at any point in a
long-lived process, and a startup-only reap could never recover from that.
"""
_bt = _origin()
"""Every 30s: close sessions idle past BROWSER_SESSION_INACTIVITY_TIMEOUT; reap
orphans on startup AND every BROWSER_ORPHAN_REAP_INTERVAL seconds."""
reap_every_cycles = max(1, round(_bt.BROWSER_ORPHAN_REAP_INTERVAL / 30))
cycle = 0
while _bt._cleanup_running:
# cycle 0 is the startup reap; then every reap_every_cycles.
if cycle % reap_every_cycles == 0:
if cycle % reap_every_cycles == 0: # cycle 0 is the startup reap
try:
_bt._reap_orphaned_browser_sessions()
except Exception as e:
@@ -470,8 +432,7 @@ def _browser_cleanup_thread_worker():
except Exception as e:
_bt.logger.warning("Cleanup thread error: %s", e)
# Sleep in 1-second intervals so we can stop quickly if needed
for _ in range(30):
for _ in range(30): # 1s granularity so stop is quick
if not _bt._cleanup_running:
break
time.sleep(1)
@@ -479,8 +440,6 @@ def _browser_cleanup_thread_worker():
def _start_browser_cleanup_thread():
"""Start the background cleanup thread if not already running."""
_bt = _origin()
with _bt._cleanup_lock:
if _bt._cleanup_thread is None or not _bt._cleanup_thread.is_alive():
_bt._cleanup_running = True
@@ -493,39 +452,30 @@ def _start_browser_cleanup_thread():
def _stop_browser_cleanup_thread():
"""Stop the background cleanup thread."""
_bt = _origin()
_bt._cleanup_running = False
if _bt._cleanup_thread is not None:
_bt._cleanup_thread.join(timeout=5)
def _update_session_activity(task_id: str):
"""Update the last activity timestamp for a session.
Also records the owning Hermes home on first sight so the process-global
janitor can tear the session down under its owner's scope. An
activity touch deliberately does NOT reset ``_cleanup_failures`` — only a
successful cleanup does.
"""
_bt = _origin()
"""Touch the activity timestamp; records the owning Hermes home on first sight so
the process-global janitor tears down under the owner's scope. Deliberately does
NOT reset ``_cleanup_failures`` — only a successful cleanup does."""
with _bt._cleanup_lock:
_bt._session_last_activity[task_id] = time.time()
_bt._session_owner_homes.setdefault(task_id, str(get_hermes_home()))
def _kill_process_tree(proc: "subprocess.Popen") -> None:
"""Best-effort kill of *proc* and every descendant it spawned; never raises.
"""Best-effort kill of *proc* and every descendant; never raises.
``Popen.kill()`` only signals the direct child. npm/npx fork helpers and
agent-browser's detached daemon grandchild, which survive a plain kill and
keep a capture pipe open so ``communicate()`` never sees EOF — on Windows
there is no non-blocking read to poll around that, so the whole tree must
go. No grace period: the caller already burned its full timeout waiting.
Delegates to :func:`agent.deadline.kill_process_tree` (taskkill /T /F,
killpg, plus a psutil sweep that reaches ``setsid``'d descendants) and
falls back to :func:`_legacy_kill_process_tree` on any failure.
``Popen.kill()`` only signals the direct child; npm/npx helpers and
agent-browser's detached daemon grandchild keep a capture pipe open so
``communicate()`` never sees EOF (no non-blocking read on Windows), so the whole
tree must go. No grace period: the caller already burned its timeout.
Delegates to :func:`agent.deadline.kill_process_tree`, falling back to
:func:`_legacy_kill_process_tree` on any failure.
"""
_bt = _origin()
try:
from agent.deadline import kill_process_tree as _deadline_kill_tree
@@ -547,12 +497,8 @@ def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
except Exception:
pass
return
# os.killpg/signal.SIGKILL don't exist on Windows; this branch is
# POSIX-only (the `os.name == "nt"` check above already returns first
# on Windows), but resolve them defensively via getattr anyway so an
# accidental future refactor that drops that guard degrades to a plain
# kill() instead of AttributeError — same discipline as
# tools/mcp_stdio_watchdog.py's _terminate_process_group.
# POSIX-only below (the nt guard returned), but resolve killpg/SIGKILL via
# getattr so a future refactor dropping that guard degrades to plain kill().
killpg = getattr(os, "killpg", None)
if killpg is None: # windows-footgun: ok - non-POSIX fallback
try:
@@ -594,59 +540,48 @@ def _pid_exists(pid: int) -> bool:
return True
def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24):
"""Remove browser screenshots older than max_age_hours to prevent disk bloat.
def _unlink_older_than(directory: Path, pattern: str, max_age_hours: float, label: str) -> None:
"""Delete ``directory/pattern`` files older than ``max_age_hours``; never raises."""
try:
cutoff = time.time() - (max_age_hours * 3600)
for f in directory.glob(pattern):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception as e:
_bt.logger.debug("Failed to clean old %s %s: %s", label, f, e)
except Exception as e:
_bt.logger.debug("%s cleanup error (non-critical): %s", label.capitalize(), e)
Throttled to run at most once per hour per directory to avoid repeated
scans on screenshot-heavy workflows.
"""
_bt = _origin()
def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24):
"""Prune old browser screenshots; throttled to once per hour per directory."""
key = str(screenshots_dir)
now = time.time()
if now - _bt._last_screenshot_cleanup_by_dir.get(key, 0.0) < 3600:
return
_bt._last_screenshot_cleanup_by_dir[key] = now
try:
cutoff = time.time() - (max_age_hours * 3600)
for f in screenshots_dir.glob("browser_screenshot_*.png"):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception as e:
_bt.logger.debug("Failed to clean old screenshot %s: %s", f, e)
except Exception as e:
_bt.logger.debug("Screenshot cleanup error (non-critical): %s", e)
_unlink_older_than(screenshots_dir, "browser_screenshot_*.png", max_age_hours, "screenshot")
def _cleanup_old_recordings(max_age_hours=72):
"""Remove browser recordings older than max_age_hours to prevent disk bloat."""
_bt = _origin()
"""Prune old browser recordings."""
try:
hermes_home = get_hermes_home()
recordings_dir = hermes_home / "browser_recordings"
if not recordings_dir.exists():
return
cutoff = time.time() - (max_age_hours * 3600)
for f in recordings_dir.glob("session_*.webm"):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception as e:
_bt.logger.debug("Failed to clean old recording %s: %s", f, e)
recordings_dir = get_hermes_home() / "browser_recordings"
except Exception as e:
_bt.logger.debug("Recording cleanup error (non-critical): %s", e)
return
if recordings_dir.exists():
_unlink_older_than(recordings_dir, "session_*.webm", max_age_hours, "recording")
def _drop_last_active_binding(task_id: str) -> None:
"""Drop stale last-active ownership after cleaning ``task_id``.
Cleaning a bare task drops its binding; cleaning a sidecar drops the binding
only if that sidecar was still the recorded owner — so a later
click/snapshot can't resurrect a cleaned sidecar on about:blank while a
primary-session binding is preserved.
A bare task drops its binding; a sidecar drops it only if that sidecar was
still the recorded owner — a later click can't resurrect a cleaned sidecar on
about:blank while a primary-session binding is preserved.
"""
_bt = _origin()
if _bt._is_local_sidecar_key(task_id):
bare_task_id = _bt._bare_task_id_for_session_key(task_id)
if _bt._last_active_session_key.get(bare_task_id) == task_id:
@@ -656,13 +591,8 @@ def _drop_last_active_binding(task_id: str) -> None:
def cleanup_browser(task_id: Optional[str] = None) -> None:
"""Clean up browser session(s) for a task (task completion / inactivity timeout).
A bare task id reaps BOTH the primary session and any hybrid local sidecar
spawned for it; a key already carrying ``::local`` (inactivity loop) reaps
only that one.
"""
_bt = _origin()
"""Clean up browser session(s) for a task: a bare task id reaps BOTH the primary
session and any hybrid local sidecar; a ``::local`` key reaps only that one."""
if task_id is None:
task_id = "default"
@@ -679,33 +609,23 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
def _kill_verified_daemon(socket_dir: str, session_name: str) -> bool:
"""Tree-kill the daemon recorded in ``<socket_dir>/<session>.pid`` if it is verifiably ours.
The .pid file lives in a world-writable temp dir and PIDs recycle: the
process must pass ``_verify_reapable_browser_daemon`` and have a start-time
fingerprint (so the kill refuses if the PID is swapped between check and
kill). Returns True when a kill was issued. Never raises.
"""
_bt = _origin()
"""Tree-kill the daemon in ``<socket_dir>/<session>.pid`` if verifiably ours
(identity check + start-time fingerprint). True when a kill was issued. Never raises."""
pid_file = os.path.join(socket_dir, f"{session_name}.pid")
if not os.path.isfile(pid_file):
return False
try:
from tools.process_registry import ProcessRegistry
daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip())
if not _bt._verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
_bt.logger.debug(
"Skipped daemon kill for %s: pid %s failed identity "
"verification", session_name, daemon_pid)
return False
from gateway.status import get_process_start_time
daemon_start = get_process_start_time(daemon_pid)
if daemon_start is None:
if not _terminate_verified_daemon(daemon_pid):
_bt.logger.debug(
"Skipped daemon kill for %s: no start-time "
"fingerprint for pid %s", session_name, daemon_pid)
return False
ProcessRegistry._terminate_host_pid(daemon_pid, daemon_start)
_bt.logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name)
return True
except (ProcessLookupError, ValueError, PermissionError, OSError):
@@ -716,21 +636,13 @@ def _kill_verified_daemon(socket_dir: str, session_name: str) -> bool:
def _release_session_resources(task_id: str, session_info: Dict[str, Any]) -> None:
"""Untrack ``task_id``, close its cloud provider session, kill its daemon.
The unconditional tail of ``_cleanup_single_browser_session``; also the
whole of the janitor's force-reap path, which skips the polite
agent-browser/Camofox ``close`` that kept failing but must still release
the cloud session and the local Chromium.
Unconditional tail of ``_cleanup_single_browser_session``; also the whole of the
janitor's force-reap path, which skips the polite ``close`` that kept failing.
"""
_bt = _origin()
bb_session_id = session_info.get("bb_session_id", "unknown")
with _bt._cleanup_lock:
_bt._active_sessions.pop(task_id, None)
_bt._session_last_activity.pop(task_id, None)
_bt._session_owner_homes.pop(task_id, None)
_bt._cleanup_failures.pop(task_id, None)
_forget_session_tracking(task_id, session=True)
# Cloud mode only — local sidecars have bb_session_id=None.
if bb_session_id:
if bb_session_id: # cloud only — local sidecars have bb_session_id=None
provider = _bt._get_cloud_provider()
if provider is not None:
try:
@@ -747,12 +659,8 @@ def _release_session_resources(task_id: str, session_info: Dict[str, Any]) -> No
def _force_reap_browser_session(task_id: str) -> None:
"""Janitor last resort after repeated cleanup failures.
Skips the ``close`` round-trips that keep failing and goes straight to
``_release_session_resources`` (cloud close + daemon kill + untrack).
"""
_bt = _origin()
"""Janitor last resort after repeated failures: skip the failing ``close``
round-trips, go straight to ``_release_session_resources``."""
_bt._stop_cdp_supervisor(task_id)
with _bt._cleanup_lock:
session_info = _bt._active_sessions.get(task_id)
@@ -764,16 +672,13 @@ def _force_reap_browser_session(task_id: str) -> None:
def _cleanup_single_browser_session(task_id: str) -> None:
"""Internal: reap a single browser session by its exact session key."""
# Stop the CDP supervisor for this task FIRST so we close our WebSocket
# before the backend tears down the underlying CDP endpoint.
_bt = _origin()
"""Reap a single browser session by its exact session key."""
# Stop the CDP supervisor FIRST so our WebSocket closes before the backend
# tears down the CDP endpoint.
_bt._stop_cdp_supervisor(task_id)
# Also clean up Camofox session if running in Camofox mode.
# Skip full close when managed persistence is enabled — the browser
# profile (and its session cookies) must survive across agent tasks.
# The inactivity reaper still frees idle resources.
# Camofox: skip the full close when managed persistence is on — the profile
# (session cookies) must survive across tasks; the inactivity reaper still frees idle resources.
if _bt._is_camofox_mode():
try:
from tools.browser_camofox import camofox_close, camofox_soft_cleanup
@@ -785,57 +690,47 @@ def _cleanup_single_browser_session(task_id: str) -> None:
_bt.logger.debug("cleanup_browser called for task_id: %s", task_id)
_bt.logger.debug("Active sessions: %s", list(_bt._active_sessions.keys()))
# Check if session exists (under lock), but don't remove yet -
# _run_browser_command needs it to build the close command.
# Look up (under lock) but don't remove yet — _run_browser_command needs the
# entry to build the close command.
with _bt._cleanup_lock:
session_info = _bt._active_sessions.get(task_id)
if session_info:
bb_session_id = session_info.get("bb_session_id", "unknown")
_bt.logger.debug("Found session for task %s: bb_session_id=%s", task_id, bb_session_id)
# Stop auto-recording before closing (saves the file)
_bt._maybe_stop_recording(task_id)
# A Lightpanda session is a process Hermes spawned itself (Browser
# Use mode); there is no agent-browser daemon to send ``close`` to.
# An expired cloud CDP URL cannot accept an agent-browser close command.
# Avoid feeding it back through _get_session_info(), which would try to
# renew the session recursively while cleanup is still in progress.
if (session_info.get("features") or {}).get("lightpanda"):
try:
from tools.browser_lightpanda import stop_lightpanda
stop_lightpanda(session_info.get("session_name", ""))
except Exception as e:
_bt.logger.warning("lightpanda stop failed for task %s: %s", task_id, e)
elif _bt._session_has_expired(session_info):
_bt.logger.debug(
"Skipping agent-browser close for expired session %s", task_id
)
else:
try:
_bt._run_browser_command(task_id, "close", [], timeout=10)
_bt.logger.debug(
"agent-browser close command completed for task %s", task_id
)
except Exception as e:
_bt.logger.warning("agent-browser close failed for task %s: %s", task_id, e)
_bt._release_session_resources(task_id, session_info)
_bt.logger.debug("Removed task %s from active sessions", task_id)
else:
if not session_info:
_bt.logger.debug("No active session found for task_id: %s", task_id)
return
_bt.logger.debug("Found session for task %s: bb_session_id=%s", task_id, session_info.get("bb_session_id", "unknown"))
_bt._maybe_stop_recording(task_id) # saves the file before close
# Lightpanda sessions are processes Hermes spawned (no daemon to ``close``).
# An expired cloud CDP URL cannot accept a close, and feeding it through
# _get_session_info() would try to renew the session recursively mid-cleanup.
if (session_info.get("features") or {}).get("lightpanda"):
try:
from tools.browser_lightpanda import stop_lightpanda
stop_lightpanda(session_info.get("session_name", ""))
except Exception as e:
_bt.logger.warning("lightpanda stop failed for task %s: %s", task_id, e)
elif _bt._session_has_expired(session_info):
_bt.logger.debug(
"Skipping agent-browser close for expired session %s", task_id
)
else:
try:
_bt._run_browser_command(task_id, "close", [], timeout=10)
_bt.logger.debug(
"agent-browser close command completed for task %s", task_id
)
except Exception as e:
_bt.logger.warning("agent-browser close failed for task %s: %s", task_id, e)
_bt._release_session_resources(task_id, session_info)
_bt.logger.debug("Removed task %s from active sessions", task_id)
def cleanup_all_browsers() -> None:
"""
Clean up all active browser sessions.
Useful for cleanup on shutdown.
"""
_bt = _origin()
"""Clean up all active browser sessions (shutdown) and reset cached lookups."""
with _bt._cleanup_lock:
task_ids = list(_bt._active_sessions.keys())
for task_id in task_ids:
@@ -848,12 +743,11 @@ def cleanup_all_browsers() -> None:
except Exception:
pass
# Reset cached lookups so they are re-evaluated on next use.
_bt._cached_agent_browser = None
_bt._agent_browser_resolved = False
_bt._discover_homebrew_node_dirs.cache_clear()
# Flip the resolved flag BEFORE nulling the cache so a concurrent
# reader never sees ``resolved=True`` with ``cache=None``.
# Flip the resolved flag BEFORE nulling the cache so a concurrent reader never
# sees ``resolved=True`` with ``cache=None``.
_bt._command_timeout_resolved = False
_bt._cached_command_timeout = None
_bt._snapshot_threshold_resolved = False
+35 -26
View File
@@ -1,10 +1,9 @@
"""Origin-module lookup shared by the ``tools.browser_tool_*`` extraction modules.
Code extracted from ``tools/browser_tool.py`` must keep reading its origin's
symbols (helpers, module state dicts, ``logger``) *through* ``tools.browser_tool``
so ``patch("tools.browser_tool.X")`` in tests is honoured. The extraction modules
must not import ``tools.browser_tool`` at import time (cycle), so they call
:func:`origin_module` lazily per call.
Extracted code must keep reading its origin's symbols (helpers, module state,
``logger``) *through* ``tools.browser_tool`` so ``patch("tools.browser_tool.X")``
in tests is honoured. The extraction modules must not import ``tools.browser_tool``
at import time (cycle), so they call :func:`origin_module` lazily per call.
"""
import sys
@@ -14,12 +13,9 @@ _ORIGIN_NAME = "tools.browser_tool"
class _NamespaceView:
"""Live attribute view over a module namespace whose module object is gone.
Used when a test purged ``sys.modules`` after importing the origin: the old
module's code still runs with its own globals, so reads must see *those*
(patched) globals, not a fresh re-import.
"""
"""Live attribute view over a module namespace whose module object is gone (a
test purged ``sys.modules`` after importing the origin): the old code still runs
with its own globals, so reads/writes must hit *those*, not a fresh re-import."""
__slots__ = ("_g",)
@@ -38,30 +34,26 @@ class _NamespaceView:
def _module_for_globals(g: dict):
"""The module object whose namespace is ``g`` (or a live view over it)."""
mod = sys.modules.get(_ORIGIN_NAME)
if mod is not None and mod.__dict__ is g:
return mod
import tools
mod = getattr(tools, "browser_tool", None)
if mod is not None and mod.__dict__ is g:
return mod
for mod in (sys.modules.get(_ORIGIN_NAME), getattr(tools, "browser_tool", None)):
if mod is not None and mod.__dict__ is g:
return mod
return _NamespaceView(g)
def origin_module():
def origin_module(_depth: int = 2):
"""Return the ``tools.browser_tool`` instance the *calling* moved function belongs to.
Resolution order, mirroring what in-file code would see:
1. the nearest enclosing frame executing ``tools.browser_tool`` code — a moved
helper called from origin code binds to that exact module copy, even after a
test purged/reloaded ``sys.modules`` (several copies may be alive);
2. a ``tools.browser_tool`` module referenced from a calling frame's globals
(``from tools import browser_tool`` in a test that calls the helper directly);
3. ``sys.modules`` / the ``tools`` package attribute / a fresh import.
Resolution order, mirroring what in-file code would see: (1) the nearest
enclosing frame executing ``tools.browser_tool`` code — binds to that exact
module copy even after a test purged/reloaded ``sys.modules``; (2) a
``tools.browser_tool`` module referenced from a calling frame's globals;
(3) ``sys.modules`` / the ``tools`` package attribute / a fresh import.
``_depth`` is the frame of the moved function's caller.
"""
try:
start = sys._getframe(2)
start = sys._getframe(_depth)
except ValueError: # called directly by the interpreter (atexit callback)
start = None
frame = start
@@ -83,3 +75,20 @@ def origin_module():
if mod is None:
import tools.browser_tool as mod
return mod
class _OriginProxy:
"""Module-level ``_bt`` stand-in: every attribute read/write forwards to the
origin module resolved at that moment, so moved functions need no per-call
``_bt = _origin()`` line."""
__slots__ = ()
def __getattr__(self, name):
return getattr(origin_module(3), name)
def __setattr__(self, name, value):
setattr(origin_module(3), name, value)
origin = _OriginProxy()
+249 -328
View File
@@ -1,9 +1,10 @@
"""agent-browser session management: daemon spawn, per-backend session creation (local/lightpanda/cdp/cloud), cached session lookup, command execution with timeout handling and output interpretation.
"""agent-browser session management: daemon spawn, per-backend session creation
(local/lightpanda/cdp/cloud), cached session lookup, command execution with timeout
handling and output interpretation.
Split out of ``tools/browser_tool.py``; every name is re-imported there so
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
Split out of ``tools/browser_tool.py``; every name is re-imported there. Origin
symbols and module state are read/written through ``_bt`` (the origin module,
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
the :data:`tools.browser_tool_origin.origin` proxy) so
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
"""
@@ -12,32 +13,39 @@ import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from tools.browser_tool_origin import origin_module as _origin
from tools.browser_tool_origin import origin as _bt
_CHROMIUM_MISSING_DOCKER_HINT = (
"Chromium browser is missing. You're running in Docker — pull "
"the latest image to get the bundled Chromium: "
"docker pull ghcr.io/nousresearch/hermes-agent:latest"
)
_CHROMIUM_MISSING_HINT = (
"Chromium browser is missing. Install it with: "
"npx agent-browser install --with-deps "
"(or: npx playwright install --with-deps chromium)"
)
def _needs_chromium_sandbox_bypass() -> bool:
"""Return True when Chromium needs --no-sandbox to start reliably."""
_bt = _origin()
"""True when Chromium needs --no-sandbox to start reliably (root, Docker, AppArmor userns)."""
if hasattr(os, "geteuid") and os.geteuid() == 0:
return True
if _bt._running_in_docker():
return True
userns_restrict = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"
try:
with open(userns_restrict, encoding="utf-8") as f:
if f.read().strip() == "1":
return True
with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f:
return f.read().strip() == "1"
except OSError:
pass
return False
return False
def _apply_chromium_sandbox_args(browser_env: Dict[str, str]) -> None:
"""Add required Chromium sandbox flags without overriding user settings."""
_bt = _origin()
if (
"AGENT_BROWSER_ARGS" not in browser_env
and "AGENT_BROWSER_CHROME_FLAGS" not in browser_env
@@ -52,18 +60,14 @@ def _apply_chromium_sandbox_args(browser_env: Dict[str, str]) -> None:
def _read_command_output_files(stdout_path: str, stderr_path: str) -> tuple[str, str]:
"""Best-effort read of agent-browser stdout/stderr temp files."""
stdout = stderr = ""
for path, slot in ((stdout_path, "stdout"), (stderr_path, "stderr")):
out = []
for path in (stdout_path, stderr_path):
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read().strip()
out.append(f.read().strip())
except OSError:
continue
if slot == "stdout":
stdout = text
else:
stderr = text
return stdout, stderr
out.append("")
return out[0], out[1]
def _unlink_command_output_files(*paths: str) -> None:
@@ -77,53 +81,46 @@ def _unlink_command_output_files(*paths: str) -> None:
def _format_browser_timeout_error(
command: str, timeout: int, stdout: str, stderr: str
) -> str:
"""Build an actionable timeout message from captured daemon output."""
_bt = _origin()
"""Actionable timeout message from captured daemon output."""
parts = [f"Command timed out after {timeout} seconds"]
detail = (stderr or stdout or "").strip()
if detail:
parts.append(detail[:1500])
combined = f"{stderr}\n{stdout}".lower()
hints: list[str] = []
if "sandbox" in combined:
hints.append(
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"
)
elif command == "open" and _bt._is_local_mode():
if _bt._running_in_docker():
hints.append(
parts.append(
"The browser daemon may still be starting or Chromium may be "
"missing. Pull the latest image: "
"docker pull ghcr.io/nousresearch/hermes-agent:latest"
)
else:
hints.append(
parts.append(
"The browser daemon may still be starting, or Chromium may be "
"missing system libraries. Install/repair with: "
"npx agent-browser install --with-deps "
"(or: npx playwright install --with-deps chromium)"
)
if hints:
parts.extend(hints)
return "\n".join(parts)
def _agent_browser_argv(browser_cmd: str) -> list:
"""Command prefix to invoke agent-browser (concrete binary or npx sentinel).
Concrete executable paths stay a single argv item (spaces intact); only the
synthetic npx sentinel expands. npx is resolved through the same
PATH + extended-PATH cascade ``_find_agent_browser`` uses — a bare
``shutil.which("npx")`` would let a broken system npx shadow a healthy
Hermes-managed one. If npx isn't found at all (Termux, bare container) the
bare name is used so Popen raises a readable ``FileNotFoundError: 'npx'``.
``--ignore-scripts``: AGENT_BROWSER_NPX_SPEC is a floating range, not an
exact pin — a compromised future patch must not run install-time scripts.
Concrete paths stay a single argv item; only the npx sentinel expands. npx is
resolved through the same PATH cascade as ``_find_agent_browser`` (a bare
``shutil.which("npx")`` would let a broken system npx shadow a healthy managed
one); if absent the bare name gives a readable ``FileNotFoundError: 'npx'``.
``--ignore-scripts``: AGENT_BROWSER_NPX_SPEC is a floating range — a compromised
future patch must not run install-time scripts.
"""
_bt = _origin()
if _bt._is_npx_agent_browser_sentinel(browser_cmd):
_npx_bin = _bt._resolve_npx_bin() or "npx"
return [_npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC]
@@ -131,15 +128,12 @@ def _agent_browser_argv(browser_cmd: str) -> list:
def _prepare_session_socket_dir(session_name: str) -> str:
"""Create the per-session agent-browser socket dir and claim it with our PID.
"""Create the per-session socket dir and claim it with our PID.
Each session gets its own dir so parallel workers don't fight over the
default socket path ("Failed to create socket directory: Permission
denied"). The owner_pid file is written BEFORE first use: another hermes
process's orphan reaper rmtree's any agent-browser-* dir in the shared
tmpdir that carries no live owner, which would delete this one mid-command.
Per-session dirs keep parallel workers from fighting over the default socket
path. The owner_pid file is written BEFORE first use: another hermes process's
orphan reaper rmtree's any ownerless agent-browser-* dir in the shared tmpdir.
"""
_bt = _origin()
socket_dir = os.path.join(_bt._socket_safe_tmpdir(), f"agent-browser-{session_name}")
os.makedirs(socket_dir, mode=0o700, exist_ok=True)
_bt._write_owner_pid(socket_dir, session_name)
@@ -147,14 +141,10 @@ def _prepare_session_socket_dir(session_name: str) -> str:
def _agent_browser_command_env(socket_dir: str) -> Dict[str, str]:
"""Credential-scrubbed env for one agent-browser command.
Adds the discovery-time PATH fallbacks, the session socket dir, and the
daemon-side idle self-termination (``AGENT_BROWSER_IDLE_TIMEOUT_MS``,
agent-browser 0.24+) mirroring the Python-side inactivity janitor —
unless the user set the idle timeout explicitly.
"""
_bt = _origin()
"""Credential-scrubbed env for one agent-browser command: discovery-time PATH
fallbacks, the session socket dir, and daemon-side idle self-termination
(``AGENT_BROWSER_IDLE_TIMEOUT_MS``, agent-browser 0.24+) mirroring the Python
janitor — unless the user set it explicitly."""
env = _bt._build_browser_env()
env["PATH"] = _bt._merge_browser_path(env.get("PATH", ""))
env["AGENT_BROWSER_SOCKET_DIR"] = socket_dir
@@ -166,16 +156,13 @@ def _agent_browser_command_env(socket_dir: str) -> Dict[str, str]:
def _popen_agent_browser(argv: List[str], env: Dict[str, str], socket_dir: str, tag: str) -> "subprocess.Popen":
"""Spawn agent-browser with stdout/stderr redirected to ``socket_dir/_stdout_<tag>``.
Temp files instead of pipes: the CLI forks a background daemon that inherits
its fds, so with pipes ``communicate()`` never sees EOF until the timeout.
Windows: CREATE_NO_WINDOW only (NOT CREATE_NEW_PROCESS_GROUP, which on
Python 3.11 cancels asyncio's running loop task and surfaces as
KeyboardInterrupt in the CLI), STARTF_USESTDHANDLES so CreateProcess hands
the child ONLY our three handles (leaked parent console handles make the
Rust binary's daemon grandchild die silently), close_fds=True for the rest.
Returns the Popen; the caller reads/unlinks the two files.
Temp files instead of pipes: the CLI forks a daemon that inherits its fds, so
with pipes ``communicate()`` never sees EOF until the timeout. Windows:
CREATE_NO_WINDOW only (NOT CREATE_NEW_PROCESS_GROUP — on 3.11 it cancels
asyncio's running task and surfaces as KeyboardInterrupt), STARTF_USESTDHANDLES
so the child gets ONLY our three handles (leaked console handles make the Rust
daemon grandchild die silently), close_fds=True for the rest.
"""
_bt = _origin()
stdout_path = os.path.join(socket_dir, f"_stdout_{tag}")
stderr_path = os.path.join(socket_dir, f"_stderr_{tag}")
stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
@@ -197,57 +184,49 @@ def _popen_agent_browser(argv: List[str], env: Dict[str, str], socket_dir: str,
os.close(stderr_fd)
def _create_local_session(task_id: str, allow_real_profile: bool = True) -> Dict[str, str]:
_bt = _origin()
import uuid
def _session_record(prefix: str, cdp_url: Optional[str], features: Dict[str, Any]) -> Dict[str, Any]:
"""Fresh session dict with a random ``<prefix>_<hex10>`` session name."""
return {
"session_name": f"{prefix}_{uuid.uuid4().hex[:10]}",
"bb_session_id": None,
"cdp_url": cdp_url,
"features": features,
}
# Real-profile consent: attach this local session (via CDP) to the user's
# browser running on a hermes-owned SNAPSHOT of their real profile, logins
# included. Fail closed on resolver/launch errors — a consented user must
# never be silently downgraded to a throwaway. The hybrid private-URL
# sidecar passes allow_real_profile=False: handing the user's cookie jar to
# an arbitrary internal host the model chose is a larger, unconsented
# exposure than the routing rule protects against (and a real-profile
# failure must not break private-URL routing).
def _create_local_session(task_id: str, allow_real_profile: bool = True) -> Dict[str, str]:
"""Local Chromium session; consented real-profile CDP attach when allowed.
Real-profile fails closed on resolver/launch errors — a consented user must
never be silently downgraded to a throwaway. The hybrid private-URL sidecar
passes ``allow_real_profile=False``: handing the user's cookie jar to an
arbitrary internal host the model chose is a larger, unconsented exposure.
"""
if allow_real_profile:
cdp_url, err = _bt._real_profile_cdp()
if err:
raise RuntimeError(err)
if cdp_url:
session_name = f"rp_{uuid.uuid4().hex[:10]}"
info = _session_record("rp", _bt._resolve_cdp_override(cdp_url), {"local": True, "real_profile": True})
_bt.logger.info(
"Created real-profile local session %s for task %s", session_name, task_id
"Created real-profile local session %s for task %s", info["session_name"], task_id
)
return {
"session_name": session_name,
"bb_session_id": None,
"cdp_url": _bt._resolve_cdp_override(cdp_url),
"features": {"local": True, "real_profile": True},
}
return info
# Browser Use mode drives whatever CDP endpoint it is handed; with
# ``browser.engine: lightpanda`` that endpoint is a Hermes-spawned
# ``lightpanda serve``. The built-in tools never reach this branch —
# they are hidden in Browser Use mode — and keep driving Lightpanda via
# ``agent-browser --engine lightpanda`` on the plain local session below.
# ``browser.engine: lightpanda`` that is a Hermes-spawned ``lightpanda serve``.
# The built-in tools never reach this branch (hidden in Browser Use mode).
if _bt._is_browser_use_cli_mode() and _bt._using_lightpanda_engine():
return _bt._create_lightpanda_session(task_id)
session_name = f"h_{uuid.uuid4().hex[:10]}"
info = _session_record("h", None, {"local": True})
_bt.logger.info("Created local browser session %s for task %s",
session_name, task_id)
return {
"session_name": session_name,
"bb_session_id": None,
"cdp_url": None,
"features": {"local": True},
}
info["session_name"], task_id)
return info
def _create_lightpanda_session(task_id: str) -> Dict[str, Any]:
"""Spawn ``lightpanda serve`` for this session key (Browser Use mode)."""
_bt = _origin()
import uuid
from tools.browser_lightpanda import launch_lightpanda
session_name = f"lp_{uuid.uuid4().hex[:10]}"
@@ -278,27 +257,19 @@ def _local_backend_process_dead(session_info: Dict[str, Any]) -> bool:
def _create_cdp_session(task_id: str, cdp_url: str) -> Dict[str, str]:
"""Create a session that connects to a user-supplied CDP endpoint."""
_bt = _origin()
import uuid
session_name = f"cdp_{uuid.uuid4().hex[:10]}"
"""Session connecting to a user-supplied CDP endpoint."""
info = _session_record("cdp", cdp_url, {"cdp_override": True})
_bt.logger.info("Created CDP browser session %s → %s for task %s",
session_name, _bt._sanitize_url_for_logs(cdp_url), task_id)
return {
"session_name": session_name,
"bb_session_id": None,
"cdp_url": cdp_url,
"features": {"cdp_override": True},
}
info["session_name"], _bt._sanitize_url_for_logs(cdp_url), task_id)
return info
def _create_cloud_session_or_fallback(task_id: str, provider) -> Dict[str, Any]:
"""Create a cloud session; fall back to local Chromium (marked degraded) on failure.
"""Cloud session; fall back to local Chromium (marked degraded) on failure.
Some cloud providers (Browser-Use v3) return an HTTP CDP discovery URL
instead of a raw websocket endpoint, so ``cdp_url`` is resolved here.
Some providers (Browser-Use v3) return an HTTP CDP discovery URL instead of a
raw websocket endpoint, so ``cdp_url`` is resolved here.
"""
_bt = _origin()
try:
session_info = provider.create_session(task_id)
if not session_info or not isinstance(session_info, dict):
@@ -322,8 +293,7 @@ def _create_cloud_session_or_fallback(task_id: str, provider) -> Dict[str, Any]:
f"Cloud provider {provider_name} failed ({e}) and local "
f"fallback also failed ({local_error})"
) from e
# Mark session as degraded for observability
if isinstance(session_info, dict):
if isinstance(session_info, dict): # mark degraded for observability
session_info = dict(session_info)
session_info["fallback_from_cloud"] = True
session_info["fallback_reason"] = str(e)
@@ -332,14 +302,11 @@ def _create_cloud_session_or_fallback(task_id: str, provider) -> Dict[str, Any]:
def _create_session_for_key(task_id: str, force_local: bool) -> Dict[str, Any]:
"""Create a fresh session for ``task_id`` (runs OUTSIDE the lock: cloud mode makes a network call).
"""Fresh session for ``task_id`` (runs OUTSIDE the lock: cloud mode makes a network call).
Precedence: CDP override > hybrid local sidecar > cloud provider > local.
The hybrid private-URL sidecar NEVER gets the real profile — presenting real
cookies to an arbitrary LAN host the model routed there is unconsented
exposure (see ``_create_local_session``).
Precedence: CDP override > hybrid local sidecar > cloud provider > local. The
hybrid sidecar NEVER gets the real profile (see ``_create_local_session``).
"""
_bt = _origin()
cdp_override = _bt._get_cdp_override()
if cdp_override and not force_local:
return _bt._create_cdp_session(task_id, cdp_override)
@@ -354,94 +321,72 @@ def _create_session_for_key(task_id: str, force_local: bool) -> Dict[str, Any]:
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
"""Get or create session info for a session key (thread-safe).
``task_id`` may carry the ``::local`` suffix (hybrid local sidecar), which
forces a local Chromium even when a cloud provider is configured. Also
starts the inactivity cleanup thread and touches activity tracking.
Returns a dict with ``session_name`` (always) plus ``bb_session_id`` /
A ``::local``-suffixed key (hybrid sidecar) forces local Chromium even with a
cloud provider configured. Also starts the inactivity thread and touches
activity tracking. Returns ``session_name`` (always) plus ``bb_session_id`` /
``cdp_url`` for cloud sessions.
"""
_bt = _origin()
if task_id is None:
task_id = "default"
# Start the cleanup thread if not running (handles inactivity timeouts)
_bt._start_browser_cleanup_thread()
# Update activity timestamp for this session
_bt._update_session_activity(task_id)
with _bt._cleanup_lock:
# Check if we already have a session for this task
existing_session = _bt._active_sessions.get(task_id)
# Suspect-session recycle: a previous command
# timeout marked this cached session suspect via the SuspectableBackend
# adapter. ensure_healthy() tears it down here, at next use, and we fall
# through to create a fresh session — the expensive recycle lives on this
# path, not on the timeout path (mark must stay cheap).
if existing_session is not None and not _bt._browser_session_backend(task_id).ensure_healthy():
# Teardown removes the activity entry; the replacement must be
# tracked by the inactivity reaper like an initial session.
def _replacement_after_teardown() -> Optional[Dict[str, Any]]:
# Teardown removes the activity entry; the replacement must be tracked by
# the reaper like an initial session. Another thread may already have
# recycled and re-created it — return that live one instead of a third.
_bt._update_session_activity(task_id)
with _bt._cleanup_lock:
replacement = _bt._active_sessions.get(task_id)
if replacement is not None and replacement is not existing_session:
# Another thread already recycled and re-created it.
return replacement
existing_session = None
return None
if existing_session is not None:
if (
# Suspect-session recycle: a previous command timeout marked this cached
# session via the SuspectableBackend adapter; the expensive recycle lives
# here at next use, not on the timeout path (mark must stay cheap).
if not _bt._browser_session_backend(task_id).ensure_healthy():
replacement = _replacement_after_teardown()
if replacement is not None:
return replacement
existing_session = None
elif (
not _bt._session_has_expired(existing_session)
and not _bt._local_backend_process_dead(existing_session)
):
return existing_session
else:
_bt.logger.info(
"Replacing expired or dead browser session for task %s", task_id
)
_bt._cleanup_single_browser_session(task_id)
replacement = _replacement_after_teardown()
if replacement is not None:
return replacement
_bt.logger.info(
"Replacing expired or dead browser session for task %s", task_id
)
_bt._cleanup_single_browser_session(task_id)
# Cleanup removes the activity entry. The replacement session must be
# tracked by the inactivity reaper just like an initial session.
_bt._update_session_activity(task_id)
# Guard against a concurrent replacement: another thread may have
# already cleaned up the expired session and created a fresh one
# while we were waiting. If so, return the live replacement instead
# of falling through to create yet another session.
with _bt._cleanup_lock:
replacement = _bt._active_sessions.get(task_id)
if replacement is not None and replacement is not existing_session:
return replacement
# Hybrid routing: session keys ending with ``::local`` force a local
# Chromium regardless of the globally-configured cloud provider. Public
# URLs in the same conversation continue to use the cloud session under
# the bare task_id key.
force_local = _bt._is_local_sidecar_key(task_id)
session_info = _bt._create_session_for_key(task_id, force_local)
with _bt._cleanup_lock:
# Double-check: another thread may have created a session while we
# were doing the network call. Use the existing one to avoid leaking
# orphan cloud sessions.
# Another thread may have created a session during the network call; use
# it to avoid leaking orphan cloud sessions.
if task_id in _bt._active_sessions:
return _bt._active_sessions[task_id]
session_info = dict(session_info)
session_info.setdefault("session_key", task_id)
session_info.setdefault("owner_task_id", _bt._bare_task_id_for_session_key(task_id))
_bt._active_sessions[task_id] = session_info
# A brand-new session is healthy by definition — drop any stale
# suspect flag left by a wedged-path eviction of its predecessor.
# A brand-new session is healthy by definition — drop any stale suspect flag.
_bt._suspect_browser_sessions.pop(task_id, None)
# Lazy-start the CDP supervisor now that the session exists (if the
# backend surfaces a CDP URL via override or session_info["cdp_url"]).
# Idempotent; swallows errors. See _ensure_cdp_supervisor for details.
# Skip for local sidecars — they have no CDP URL — and for Lightpanda
# sessions: those only exist in Browser Use mode, where the browser_*
# tools that consume supervisor state are hidden, so the supervisor
# would just hold an idle second CDP connection to the process.
# Lazy-start the CDP supervisor (idempotent; swallows errors). Skip for local
# sidecars (no CDP URL) and Lightpanda sessions (Browser Use mode hides the
# browser_* tools that consume supervisor state; it would just idle a second CDP connection).
if not force_local and not (session_info.get("features") or {}).get("lightpanda"):
_bt._ensure_cdp_supervisor(task_id)
@@ -452,13 +397,11 @@ def _discard_timed_out_browser_session(
task_id: str, session_info: Dict[str, Any], task_socket_dir: str
) -> None:
"""Drop a stuck client generation without losing cloud cleanup state."""
_bt = _origin()
with _bt._cleanup_lock:
if _bt._active_sessions.get(task_id) is not session_info:
return
_bt._stop_cdp_supervisor(task_id)
if session_info.get("bb_session_id") or session_info.get("cdp_url"):
import uuid
replacement = dict(session_info)
replacement["session_name"] = f"h_{uuid.uuid4().hex[:10]}"
replacement.pop("_first_nav", None)
@@ -472,23 +415,21 @@ def _discard_timed_out_browser_session(
_bt._last_active_session_key.pop(bare_task_id, None)
session_name = str(session_info.get("session_name") or "")
if session_name:
pid_file = os.path.join(task_socket_dir, f"{session_name}.pid")
if os.path.isfile(pid_file):
try:
daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip())
if not _bt._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name):
return
# Tree-kill: the daemon spawns Chromium
# children; terminating only the daemon PID leaks the whole
# Chromium tree. agent.deadline.kill_process_tree escalates
# SIGTERM → SIGKILL across the tree.
from agent import deadline as _deadline
if session_name and os.path.isfile(os.path.join(task_socket_dir, f"{session_name}.pid")):
daemon_pid = _bt._read_browser_daemon_pid(task_socket_dir, session_name)
if daemon_pid is None: # corrupt pid file
_bt.logger.debug("Could not kill timed-out browser daemon for %s", session_name)
return
if not _bt._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name):
return
try:
# Tree-kill: terminating only the daemon PID leaks the Chromium tree.
from agent import deadline as _deadline
_deadline.kill_process_tree(daemon_pid)
except (ProcessLookupError, ValueError, PermissionError, OSError):
_bt.logger.debug("Could not kill timed-out browser daemon for %s", session_name)
return
_deadline.kill_process_tree(daemon_pid)
except (ProcessLookupError, PermissionError, OSError):
_bt.logger.debug("Could not kill timed-out browser daemon for %s", session_name)
return
shutil.rmtree(task_socket_dir, ignore_errors=True)
@@ -504,9 +445,9 @@ def _read_browser_daemon_pid(task_socket_dir: str, session_name: str) -> Optiona
def _browser_daemon_responsive(task_socket_dir: str, probe_timeout_s: float = 1.0) -> bool:
"""Cheap liveness probe: connect to the daemon's unix control socket.
A successful connect proves the accept loop is alive (the command wedged on
the page/CDP side, not the daemon). Windows uses named pipes — no probe is
possible, so report unresponsive (tree-kill + respawn is the safe recovery).
A successful connect proves the accept loop is alive (the command wedged on the
page/CDP side). Windows uses named pipes — no probe possible, so report
unresponsive (tree-kill + respawn is the safe recovery).
"""
if os.name == "nt":
return False
@@ -518,14 +459,13 @@ def _browser_daemon_responsive(task_socket_dir: str, probe_timeout_s: float = 1.
entries = os.listdir(task_socket_dir)
except OSError:
return False
sock_paths = [
os.path.join(task_socket_dir, e) for e in entries if e.endswith(".sock")
]
for sock_path in sock_paths:
for entry in entries:
if not entry.endswith(".sock"):
continue
try:
with socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) as s:
s.settimeout(probe_timeout_s)
s.connect(sock_path)
s.connect(os.path.join(task_socket_dir, entry))
return True
except OSError:
continue
@@ -537,20 +477,16 @@ def _handle_browser_command_timeout(
) -> None:
"""Recover session state after a browser command timeout.
* Cloud / CDP sessions: no local daemon to probe — replace the stuck client
generation now (fresh ``session_name``, same ``bb_session_id`` so cloud
cleanup still works).
* Local daemon alive (PID live, identity-verified, control socket accepts):
only the *command* wedged; mark the session suspect and let the next use
recycle it via ``ensure_healthy`` → clean ``close`` → fresh session.
* Cloud / CDP: no local daemon to probe — replace the stuck client generation
now (fresh ``session_name``, same ``bb_session_id`` so cloud cleanup works).
* Local daemon alive (PID live, identity-verified, control socket accepts): only
the *command* wedged; mark suspect and let next use recycle via ``ensure_healthy``.
* Local daemon wedged/dead: it cannot service a clean close and its Chromium
children would leak — tree-kill and evict now; the next call respawns.
children would leak — tree-kill and evict now.
Both local branches ``mark_suspect`` first (cheap, lock-free) so the
poisoned-cache invariant holds even if eviction races another thread's
replacement (the flag then costs one harmless no-op teardown).
poisoned-cache invariant holds even if eviction races another thread's replacement.
"""
_bt = _origin()
if session_info.get("bb_session_id") or session_info.get("cdp_url"):
_bt._discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
return
@@ -579,10 +515,8 @@ def _handle_browser_command_timeout(
"tree-killing and evicting the session", task_id,
)
_bt._discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
# The poisoned entry is gone (evicted, or superseded by a concurrent
# replacement discard refused to touch) — either way the cache no longer
# holds the timed-out session, so drop the flag: it must not poison a
# session created later under the same key.
# The poisoned entry is gone either way; the flag must not poison a session
# created later under the same key.
_bt._suspect_browser_sessions.pop(task_id, None)
@@ -590,11 +524,10 @@ def _interpret_browser_command_output(command: str, stdout: str, stderr: str, re
"""Turn a finished agent-browser process's output into a result dict.
Empty stdout with rc=0 is a broken state (stale daemon) and is reported as
failure rather than a silent ``{"success": True, "data": {}}`` — except for
commands in ``_EMPTY_OK_COMMANDS``. Non-JSON output is an error, except
for ``screenshot`` where the saved path is recovered from the prose.
failure rather than a silent success — except for ``_EMPTY_OK_COMMANDS``.
Non-JSON output is an error, except ``screenshot`` where the saved path is
recovered from the prose.
"""
_bt = _origin()
if stderr and stderr.strip():
level = logging.WARNING if returncode != 0 else logging.DEBUG
_bt.logger.log(level, "browser '%s' stderr: %s", command, stderr.strip()[:500])
@@ -637,6 +570,93 @@ def _interpret_browser_command_output(command: str, stdout: str, stderr: str, re
return parsed
def _browser_command_preflight() -> Dict[str, Any]:
"""Fail fast before spawning: missing CLI, Termux install gap, interrupt, or no
Chromium in local mode (else every call hangs for command_timeout). Returns an
error result, or ``{"browser_cmd": path}`` on success."""
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError as e:
_bt.logger.warning("agent-browser CLI not found: %s", e)
return {"success": False, "error": str(e)}
if _bt._requires_real_termux_browser_install(browser_cmd):
error = _bt._termux_browser_install_error()
_bt.logger.warning("browser command blocked on Termux: %s", error)
return {"success": False, "error": error}
# Skip when engine=lightpanda — LP doesn't need Chromium for navigation.
if (
_bt._is_local_mode()
and not _bt._chromium_installed()
and _bt._get_browser_engine() != "lightpanda"
and not _bt._maybe_autoinstall_chromium()
):
hint = _CHROMIUM_MISSING_DOCKER_HINT if _bt._running_in_docker() else _CHROMIUM_MISSING_HINT
_bt.logger.warning("browser command blocked: %s", hint)
return {"success": False, "error": hint}
from tools.interrupt import is_interrupted
if is_interrupted():
return {"success": False, "error": "Interrupted"}
return {"browser_cmd": browser_cmd}
def _spawn_and_collect(
task_id: str, session_info: Dict[str, Any], cmd_parts: List[str],
command: str, engine: str, timeout: int,
) -> Dict[str, Any]:
"""Run the prepared agent-browser argv once and interpret its output (handles timeout)."""
task_socket_dir = _bt._prepare_session_socket_dir(session_info["session_name"])
_bt.logger.debug("browser cmd=%s task=%s socket_dir=%s (%d chars)",
command, task_id, task_socket_dir, len(task_socket_dir))
browser_env = _bt._agent_browser_command_env(task_socket_dir)
# Lightpanda rejects Chromium-only launch flags: strip current and legacy vars;
# Chrome commands and fallback use the shared Chromium policy.
if engine == "lightpanda":
_stripped_args = browser_env.pop("AGENT_BROWSER_ARGS", None)
_stripped_flags = browser_env.pop("AGENT_BROWSER_CHROME_FLAGS", None)
if _stripped_args is not None or _stripped_flags is not None:
_bt.logger.debug(
"browser: stripped Chromium-only AGENT_BROWSER_ARGS/"
"AGENT_BROWSER_CHROME_FLAGS for Lightpanda command %s "
"(agent-browser rejects them with --engine lightpanda)",
command,
)
else:
_bt._apply_chromium_sandbox_args(browser_env)
stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
proc = _bt._popen_agent_browser(cmd_parts, browser_env, task_socket_dir, command)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stdout, stderr = _bt._read_command_output_files(stdout_path, stderr_path)
_bt._unlink_command_output_files(stdout_path, stderr_path)
_bt._handle_browser_command_timeout(task_id, session_info, task_socket_dir)
if stderr and stderr.strip():
_bt.logger.warning(
"browser '%s' stderr after timeout: %s", command, stderr.strip()[:500]
)
_bt.logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
command, timeout, task_id, task_socket_dir)
return {
"success": False,
"error": _bt._format_browser_timeout_error(command, timeout, stdout, stderr),
}
with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read()
with open(stderr_path, "r", encoding="utf-8") as f:
stderr = f.read()
_bt._unlink_command_output_files(stdout_path, stderr_path)
return _bt._interpret_browser_command_output(command, stdout, stderr, proc.returncode)
def _run_browser_command(
task_id: str,
command: str,
@@ -650,52 +670,15 @@ def _run_browser_command(
``_engine_override`` forces an engine for this call only (the Lightpanda
fallback uses it to retry with Chrome without touching global state).
"""
_bt = _origin()
if timeout is None:
timeout = _bt._safe_command_timeout()
args = args or []
# Build the command
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError as e:
_bt.logger.warning("agent-browser CLI not found: %s", e)
return {"success": False, "error": str(e)}
preflight = _browser_command_preflight()
if "browser_cmd" not in preflight:
return preflight
browser_cmd = preflight["browser_cmd"]
if _bt._requires_real_termux_browser_install(browser_cmd):
error = _bt._termux_browser_install_error()
_bt.logger.warning("browser command blocked on Termux: %s", error)
return {"success": False, "error": error}
# Local mode with no Chromium on disk: fail fast with an actionable
# message instead of hanging for _command_timeout seconds per call.
# Skip when engine=lightpanda — LP doesn't need Chromium for navigation.
if (
_bt._is_local_mode()
and not _bt._chromium_installed()
and _bt._get_browser_engine() != "lightpanda"
and not _bt._maybe_autoinstall_chromium()
):
if _bt._running_in_docker():
hint = (
"Chromium browser is missing. You're running in Docker — pull "
"the latest image to get the bundled Chromium: "
"docker pull ghcr.io/nousresearch/hermes-agent:latest"
)
else:
hint = (
"Chromium browser is missing. Install it with: "
"npx agent-browser install --with-deps "
"(or: npx playwright install --with-deps chromium)"
)
_bt.logger.warning("browser command blocked: %s", hint)
return {"success": False, "error": hint}
from tools.interrupt import is_interrupted
if is_interrupted():
return {"success": False, "error": "Interrupted"}
# Get session info (creates Browserbase session with proxies if needed)
try:
session_info = _bt._get_session_info(task_id)
except Exception as e:
@@ -705,25 +688,17 @@ def _run_browser_command(
if command != "close" and session_info.get("cdp_url"):
_bt._ensure_cdp_supervisor(task_id)
# Build the command with the appropriate backend flag.
# Cloud mode: --cdp <websocket_url> connects to Browserbase.
# Local mode: --session <name> launches a local headless Chromium.
# The rest of the command (--json, command, args) is identical.
# Cloud/CDP: ``--cdp <ws_url>`` (NEVER with --session: agent-browser >=0.13
# would create a local browser and silently ignore --cdp). Local: ``--session <name>``.
if session_info.get("cdp_url"):
# Cloud mode — connect to remote Browserbase browser via CDP
# IMPORTANT: Do NOT use --session with --cdp. In agent-browser >=0.13,
# --session creates a local browser instance and silently ignores --cdp.
backend_args = ["--cdp", session_info["cdp_url"]]
else:
# Local mode — launch Chromium (headless by default, headed when configured)
backend_args = ["--session", session_info["session_name"]]
if _bt._is_headed_mode():
backend_args.append("--headed")
# Lightpanda engine injection (local mode only, agent-browser v0.25.3+).
# Use the resolved session backend rather than global cloud-provider state:
# hybrid private-URL routing can create a local sidecar while a cloud
# provider remains configured for public URLs.
# Engine injection keys off the resolved session backend, not global provider
# state: hybrid routing can create a local sidecar while a cloud provider stays configured.
engine = _engine_override or _bt._get_browser_engine()
if engine != "auto" and not _bt._is_camofox_mode() and not session_info.get("cdp_url"):
backend_args += ["--engine", engine]
@@ -731,65 +706,13 @@ def _run_browser_command(
cmd_parts = _bt._agent_browser_argv(browser_cmd) + backend_args + ["--json", command] + args
try:
task_socket_dir = _bt._prepare_session_socket_dir(session_info["session_name"])
_bt.logger.debug("browser cmd=%s task=%s socket_dir=%s (%d chars)",
command, task_id, task_socket_dir, len(task_socket_dir))
browser_env = _bt._agent_browser_command_env(task_socket_dir)
# Chromium-only launch flags are rejected by Lightpanda. Strip both
# the current and legacy variables for Lightpanda commands; explicit
# Chrome commands and fallback use the shared Chromium policy.
if engine == "lightpanda":
_stripped_args = browser_env.pop("AGENT_BROWSER_ARGS", None)
_stripped_flags = browser_env.pop("AGENT_BROWSER_CHROME_FLAGS", None)
if _stripped_args is not None or _stripped_flags is not None:
_bt.logger.debug(
"browser: stripped Chromium-only AGENT_BROWSER_ARGS/"
"AGENT_BROWSER_CHROME_FLAGS for Lightpanda command %s "
"(agent-browser rejects them with --engine lightpanda)",
command,
)
else:
_bt._apply_chromium_sandbox_args(browser_env)
stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
proc = _bt._popen_agent_browser(cmd_parts, browser_env, task_socket_dir, command)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stdout, stderr = _bt._read_command_output_files(stdout_path, stderr_path)
_bt._unlink_command_output_files(stdout_path, stderr_path)
_bt._handle_browser_command_timeout(task_id, session_info, task_socket_dir)
if stderr and stderr.strip():
_bt.logger.warning(
"browser '%s' stderr after timeout: %s", command, stderr.strip()[:500]
)
_bt.logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
command, timeout, task_id, task_socket_dir)
result = {
"success": False,
"error": _bt._format_browser_timeout_error(command, timeout, stdout, stderr),
}
# Fall through to fallback check below
else:
with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read()
with open(stderr_path, "r", encoding="utf-8") as f:
stderr = f.read()
_bt._unlink_command_output_files(stdout_path, stderr_path)
result = _bt._interpret_browser_command_output(command, stdout, stderr, proc.returncode)
result = _spawn_and_collect(task_id, session_info, cmd_parts, command, engine, timeout)
except Exception as e:
_bt.logger.warning("browser '%s' exception: %s", command, e, exc_info=True)
result = {"success": False, "error": str(e)}
# --- Lightpanda automatic Chrome fallback ---
# If engine is lightpanda and the result looks broken, retry with Chrome.
# This runs for ALL exit paths (timeout, empty, non-JSON, nonzero rc, parsed).
# Lightpanda automatic Chrome fallback — runs for ALL exit paths (timeout,
# empty, non-JSON, nonzero rc, parsed).
fallback_reason = _bt._lightpanda_fallback_reason(engine, command, result)
if fallback_reason:
_bt.logger.info(
@@ -798,9 +721,7 @@ def _run_browser_command(
task_id,
fallback_reason,
)
# For screenshots, use the dedicated Chrome fallback helper
# (spins up a separate Chrome session to the same URL).
if command == "screenshot":
if command == "screenshot": # separate Chrome session to the same URL
fallback_result = _bt._chrome_fallback_screenshot(task_id, args or [], timeout)
else:
fallback_result = _bt._run_chrome_fallback_command(task_id, command, args, timeout)
+15 -23
View File
@@ -8,7 +8,14 @@ at import time (cycle).
import re
from typing import Any, Optional
from tools.browser_tool_origin import origin_module as _origin
from tools.browser_tool_origin import origin as _bt
_SCREENSHOT_PATH_PATTERNS = (
r"Screenshot saved to ['\"](?P<path>/[^'\"]+?\.png)['\"]",
r"Screenshot saved to (?P<path>/\S+?\.png)(?:\s|$)",
r"(?P<path>/\S+?\.png)(?:\s|$)",
)
def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
@@ -16,19 +23,12 @@ def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
if not text:
return None
patterns = [
r"Screenshot saved to ['\"](?P<path>/[^'\"]+?\.png)['\"]",
r"Screenshot saved to (?P<path>/\S+?\.png)(?:\s|$)",
r"(?P<path>/\S+?\.png)(?:\s|$)",
]
for pattern in patterns:
for pattern in _SCREENSHOT_PATH_PATTERNS:
match = re.search(pattern, text)
if match:
path = match.group("path").strip().strip("'\"")
if path:
return path
return None
@@ -40,7 +40,6 @@ def _store_full_snapshot(snapshot_text: str) -> Optional[str]:
backend. The stored copy is force-redacted (page-rendered secrets must not
hit disk unmasked) and named by content hash so identical snapshots dedupe.
"""
_bt = _origin()
try:
import hashlib
from hermes_constants import get_hermes_dir
@@ -59,11 +58,9 @@ def _store_full_snapshot(snapshot_text: str) -> Optional[str]:
ensure_spill_dir(cache_dir, private=False)
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
path = cache_dir / f"browser-snapshot-{digest}.txt"
# Deterministic filename in a well-known dir: refuse symlinks via
# lstat-unlink + exclusive create. Re-snapshotting the same page
# state legitimately overwrites (same content-hash name). Not
# private: cache/web is bind-mounted into remote backends whose
# container UID must be able to read it.
# Deterministic filename in a well-known dir: refuse symlinks (lstat-unlink +
# exclusive create); same-content re-snapshots legitimately overwrite. Not
# private: cache/web is bind-mounted into remote backends' container UID.
write_text_exclusive(path, content, private=False, overwrite=True)
return str(path)
except Exception as exc: # noqa: BLE001
@@ -78,7 +75,6 @@ def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> s
cache/web and the appended note tells the agent how to page through it
with read_file — element refs beyond the cut are in the file, not lost.
"""
_bt = _origin()
if max_chars is None:
max_chars = _bt.get_browser_snapshot_threshold()
if len(snapshot_text) <= max_chars:
@@ -111,13 +107,9 @@ def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> s
def _redact_browser_output(value: Any) -> Any:
"""Redact secrets from browser-originated data before returning to the model.
Browser snapshots, console messages, JS exceptions, and eval results can
contain page-rendered API keys, cookies, bearer tokens, or pasted secrets.
Tool output is a model boundary, so force redaction here even if global log
redaction is disabled for debugging.
"""
"""Force-redact secrets in browser-originated data (snapshots, console, eval
results can carry page-rendered keys/cookies/tokens). Tool output is a model
boundary, so this applies even when global log redaction is disabled."""
from agent.redact import redact_sensitive_text
if isinstance(value, str):
+23 -38
View File
@@ -1,10 +1,9 @@
"""browser_vision helpers: Lightpanda pre-route, native provider vision, auxiliary-LLM screenshot analysis.
Split out of ``tools/browser_tool.py``; every name is re-imported there so
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
symbols and module state are read/written through ``_bt`` (the origin module,
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
Split out of ``tools/browser_tool.py``; every name is re-imported there. Origin
symbols are read through ``_bt`` (the
:data:`tools.browser_tool_origin.origin` proxy) so ``patch("tools.browser_tool.X")``
is honoured and no import cycle exists.
"""
import os
@@ -12,11 +11,10 @@ import shutil
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from tools.browser_tool_origin import origin_module as _origin
from tools.browser_tool_origin import origin as _bt
def _vision_mode_label() -> str:
_bt = _origin()
_cp = _bt._get_cloud_provider()
return "local" if _cp is None else f"cloud ({_cp.provider_name()})"
@@ -24,15 +22,10 @@ def _vision_mode_label() -> str:
def _lightpanda_vision_preroute(
effective_task_id: str, annotate: bool, screenshot_path: Path,
) -> Tuple[bool, Optional[str], Path]:
"""Capture the vision screenshot through the Chrome fallback when Lightpanda is the engine.
Lightpanda has no graphical renderer, so the normal path would fail with a
CDP error or return a placeholder PNG. Returns ``(prerouted, fallback_warning,
screenshot_path)``; on fallback failure ``prerouted`` is False and the caller
takes the normal screenshot path (forcing Chrome) so ``_run_browser_command``
still produces the standard fallback metadata/error.
"""
_bt = _origin()
"""Capture the vision screenshot via the Chrome fallback when Lightpanda is the engine
(it has no graphical renderer). Returns ``(prerouted, fallback_warning, path)``;
on fallback failure ``prerouted`` is False and the caller takes the normal
screenshot path (forcing Chrome) so the standard fallback metadata still applies."""
engine = _bt._get_browser_engine()
if engine != "lightpanda" or not _bt._should_inject_engine(engine):
return False, None, screenshot_path
@@ -62,11 +55,9 @@ def _native_vision_result(
) -> Dict[str, Any]:
"""Multimodal tool-result envelope: the main model inspects the pixels itself.
History-reuse cap: this embed is baked into the tool result and re-sent on
every later turn, exactly like vision_analyze's native path — apply the same
proactive resize so full-res screenshots can't enter immutable history
uncapped. The helper's stat/dimension quick-estimate skips the resize when
already under both caps; without Pillow it fails open to the raw bytes.
The embed is baked into history and re-sent every later turn, so apply the same
proactive resize as vision_analyze's native path (skipped when already under
both caps; without Pillow it fails open to the raw bytes).
"""
from tools.vision_tools import (
_EMBED_MAX_DIMENSION,
@@ -103,12 +94,10 @@ def _native_vision_result(
def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> str:
"""One-shot aux vision-LLM analysis (not baked into history), secret-redacted.
Encodes at full resolution; on a size-related provider rejection the image
is downscaled once and retried. Timeout/temperature come from
``auxiliary.vision.*`` — local vision models (llama.cpp, ollama) can take
well over 30s, so the default timeout is generous.
Full resolution first; on a size-related provider rejection the image is
downscaled once and retried. ``auxiliary.vision.timeout/temperature`` — local
vision models can take well over 30s, so the default timeout is generous.
"""
_bt = _origin()
import base64
vision_prompt = (
@@ -131,12 +120,10 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
try:
from hermes_cli.config import load_config
_vision_cfg = _bt.cfg_get(load_config(), "auxiliary", "vision", default={})
_vt = _vision_cfg.get("timeout")
if _vt is not None:
vision_timeout = float(_vt)
_vtemp = _vision_cfg.get("temperature")
if _vtemp is not None:
vision_temperature = float(_vtemp)
if _vision_cfg.get("timeout") is not None:
vision_timeout = float(_vision_cfg["timeout"])
if _vision_cfg.get("temperature") is not None:
vision_temperature = float(_vision_cfg["temperature"])
except Exception:
pass
@@ -157,7 +144,7 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
if vision_model:
call_kwargs["model"] = vision_model
try:
response = _bt._lazy_call_llm(**call_kwargs)
response = _bt.call_llm(**call_kwargs)
except Exception as _api_err:
from tools.vision_tools import (
_is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES,
@@ -172,9 +159,7 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
)
data_url = _resize_image_for_vision(screenshot_path, mime_type="image/png")
call_kwargs["messages"][0]["content"][1]["image_url"]["url"] = data_url
response = _bt._lazy_call_llm(**call_kwargs)
response = _bt.call_llm(**call_kwargs)
analysis = (response.choices[0].message.content or "").strip()
# Redact secrets the vision LLM may have read from the screenshot.
from agent.redact import redact_sensitive_text
return redact_sensitive_text(analysis)
from agent.redact import redact_sensitive_text # the LLM may have read secrets off the screenshot
return redact_sensitive_text((response.choices[0].message.content or "").strip())