From f8546c2eace03a07d3d4abfe9ca97e5bc98f30da Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:18:05 -0700 Subject: [PATCH] =?UTF-8?q?fix(browser):=20real-profile=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20reap=20launched=20Chrome,=20headless=20display-less?= =?UTF-8?q?=20Linux,=20register=20real=5Fprofile=5Fpin=20default=20+=20doc?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _terminate_real_profile_chrome(): directly-launched real browsers are ours to reap (agent-browser only attaches); wired into the atexit emergency cleanup and both launch-failure paths so orphaned Chrome processes can't accumulate. - Display-less Linux gate: append --headless=new (shares the profile's normal cookie store, unlike legacy headless) so the direct-launch path doesn't regress servers without DISPLAY/WAYLAND_DISPLAY. - Register browser.real_profile_pin in config_defaults.py and document the new launch model + pin in website/docs/user-guide/features/browser.md. - Drop unused tempfile import from the cherry-picked commit. --- .../emails/jason@runninwithitmarketing.com | 1 + hermes_cli/config_defaults.py | 8 ++++ tools/browser_tool.py | 38 ++++++++++++++++++- website/docs/user-guide/features/browser.md | 23 ++++++++++- 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 contributors/emails/jason@runninwithitmarketing.com diff --git a/contributors/emails/jason@runninwithitmarketing.com b/contributors/emails/jason@runninwithitmarketing.com new file mode 100644 index 0000000000..dbb2a5774d --- /dev/null +++ b/contributors/emails/jason@runninwithitmarketing.com @@ -0,0 +1 @@ +runninwithitmarketing diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 682b6be40f..76b38121ff 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -611,6 +611,14 @@ DEFAULT_CONFIG = { # retry. Still locked afterward → stays blocked, no loop, no auto-kill. # OFF by default. No effect on macOS/Linux (copy-while-running works). "real_profile_autoclose": False, + # Pin WHICH source browser profile directory gets snapshotted for + # real-profile browsing (e.g. "Profile 2"). Unset/empty: follows the + # browser's last-used profile (Local State → profile.last_used). On a + # machine with several profiles (work + personal), last-used roulette + # can silently hand the agent the wrong identity; a pin locks it. A pin + # naming a directory that doesn't exist FAILS CLOSED with a fixable + # message rather than falling back to last-used. + "real_profile_pin": "", "allow_unsafe_evaluate": False, # Legacy override: when true, browser_console(expression=...) bypasses the restrict_evaluate denylist entirely "restrict_evaluate": False, # Opt-in denylist blocking sensitive JS primitives (cookies/storage/clipboard/network/form values) in browser_console(expression=...) # CDP supervisor — dialog + frame detection via a persistent WebSocket. diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 8d7ee6a395..cd30328c88 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1457,6 +1457,27 @@ _real_profile_cdp_cache: dict = {} _real_profile_chrome_procs: list = [] # Popen handles of directly-launched real browsers +def _terminate_real_profile_chrome() -> None: + """Terminate real-browser processes launched for real-profile sessions. + + The real-profile path launches the user's actual browser binary on the + profile COPY (bypassing agent-browser's mock-keychain launch). Those + processes are ours to reap: agent-browser only ATTACHED to them, so its + own session cleanup never kills them. Idempotent; safe from atexit. + """ + while _real_profile_chrome_procs: + proc = _real_profile_chrome_procs.pop() + try: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + except Exception as e: + logger.debug("real-profile chrome terminate failed: %s", e) + + def _agent_browser_argv(browser_cmd: str) -> list: """Command prefix to invoke agent-browser (binary or npx sentinel).""" if _is_npx_agent_browser_sentinel(browser_cmd): @@ -1671,7 +1692,6 @@ def _real_profile_cdp() -> tuple: "browser.use_real_profile is on, but the real browser binary for " f"'{browser}' could not be found. Reinstall it or turn the toggle off." ) - import tempfile port_file = os.path.join(copy_dir, "DevToolsActivePort") try: @@ -1694,6 +1714,14 @@ def _real_profile_cdp() -> tuple: "--disable-features=Translate", "--no-startup-window", ] + if sys.platform.startswith("linux") and not ( + os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + ): + # Display-less Linux (servers, CI): the real binary cannot open a + # window, so it exits at startup. Chrome's NEW headless mode shares + # the profile's normal cookie store (unlike legacy --headless with + # its separate store), so real-profile auth still loads. + chrome_argv.append("--headless=new") try: chrome_proc = subprocess.Popen( chrome_argv, @@ -1722,12 +1750,14 @@ def _real_profile_cdp() -> tuple: except OSError: pass if chrome_proc.poll() is not None: + _terminate_real_profile_chrome() return None, ( "browser.use_real_profile is on, but Chrome exited during " "startup (another instance may hold the profile copy)." ) _time.sleep(0.25) if port is None: + _terminate_real_profile_chrome() return None, ( "browser.use_real_profile is on, but the real-profile browser " "did not expose a debug port in time. Retry, or turn the toggle off." @@ -2197,6 +2227,12 @@ def _emergency_cleanup_all_sessions(): # 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. + try: + _terminate_real_profile_chrome() + except Exception as e: + logger.debug("Real-profile chrome cleanup on exit failed: %s", e) if _active_sessions: logger.info("Emergency cleanup: closing %s active session(s)...", len(_active_sessions)) diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index b96e077374..af7d5f5cb6 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -173,8 +173,13 @@ browser: When enabled, Hermes copies your default browser's **active** profile — the one you actually browse (`Local State → profile.last_used`), with its cookies, saved logins, and preferences — into a managed snapshot under -`~/.hermes/browser-profile//`, then drives that snapshot with its -packaged Chromium. Your live browser profile is **never opened directly**: the +`~/.hermes/browser-profile//`, then launches your **real browser +binary** on that snapshot and attaches its browsing engine to it. Launching the +real binary (instead of a bundled Chromium with mock-keychain switches) is what +keeps OS-encrypted cookies decryptable — on macOS, Chrome cookies are encrypted +through the Keychain, and a mock-keychain launch would silently drop every one +of them, opening signed out. Your live browser profile is **never opened +directly**: the snapshot is a separate directory, so it doesn't fight your running browser for the profile lock and it sidesteps Chrome 136+'s block on remote-debugging the default profile directory. The auth files (cookies/logins/preferences) are @@ -182,6 +187,20 @@ re-synced from your real profile whenever a fresh session is launched, so logins you do in your own browser show up in the agent's session. Only the active profile is copied — other Chrome profiles are never snapshotted. +If your browser has several profiles (say a work profile and a personal one) +and you don't want "whichever profile you touched last" deciding the agent's +identity, pin the snapshot source explicitly: + +```yaml +# ~/.hermes/config.yaml +browser: + use_real_profile: true + real_profile_pin: "Profile 2" # directory name under the browser's user-data dir +``` + +A pin naming a profile directory that doesn't exist fails closed with a +fixable message — it never silently falls back to the last-used profile. + When you turn the toggle back off, Hermes deletes the snapshot store (`~/.hermes/browser-profile/`) on the next browser use, so the copied credentials don't linger after you revoke consent.