Merge branch 'simp/r3-31-A' into simp/integration3
This commit is contained in:
+401
-676
File diff suppressed because it is too large
Load Diff
+187
-293
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user