feat(browser): consented auto-close of a running browser for Windows real-profile [proof workflow do-not-merge]
Live Windows CI proved copy-while-running is impossible (Chrome opens the cookie DB deny-all). So to make Windows actually WORK — not just fail cleanly — add opt-in auto-close: browser.real_profile_autoclose (default false). When the profile is locked and consent is on, snapshot_real_profile terminates the browser process tree bound to THAT user-data-dir (psutil, identity+binding verified like the daemon reaper — browser binary AND this exact --user-data-dir in cmdline, fail-closed on ambiguity), waits for the lock to release, then snapshots. Destructive (loses unsaved tabs) so it's off by default and the agent asks first; the fail-fast message names the option. No effect on POSIX. - close_browser_holding_profile: graceful terminate → kill → poll until the cookie DB is openable again (bounded); reports relaunch/tray failure clearly. - _processes_holding_profile: identity+binding matcher (never kills an unrelated same-name process on a different dir). - Config key + docs admonition. Tests: autoclose closes-then-snapshots, autoclose-failure-reports, fail-fast names the option, process-matcher identity/binding. 74 real-profile tests pass. Windows live E2E (PROOF workflow, reverted before merge): autoclose-off fails fast <30s; autoclose-on terminates real Chrome, lock releases, valid cookie DB copied.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
name: Windows real-profile live E2E
|
||||
|
||||
# ON-DEMAND PROOF (real-profile browsing, PR #95620 — Windows auto-close).
|
||||
#
|
||||
# Proves on a REAL windows-latest runner that consented auto-close makes
|
||||
# real-profile browsing work with a running Chrome (terminates the locking
|
||||
# process tree, lock releases, valid copy produced) and that the default
|
||||
# (autoclose off) fails fast rather than hanging.
|
||||
#
|
||||
# PROOF workflow — reverted before merge; must not land on main. Per-sha
|
||||
# concurrency + no cancel-in-progress so runs complete.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feat/real-profile-cdp"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-realprofile-e2e-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
real-profile-e2e:
|
||||
name: real-profile auto-close live E2E (windows-latest)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Google Chrome
|
||||
shell: pwsh
|
||||
run: choco install googlechrome --no-progress -y --ignore-checksums
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
version: "0.9.28"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run real-profile auto-close live E2E
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
uv run --no-sync python -m pytest \
|
||||
tests/hermes_cli/test_real_profile_windows_live.py \
|
||||
-o addopts= -v -s -p no:cacheprovider
|
||||
@@ -662,6 +662,118 @@ def _profile_is_locked(src: str, source_profile: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _real_profile_autoclose() -> bool:
|
||||
"""Whether browser.real_profile_autoclose consent is on (config read).
|
||||
|
||||
When true, snapshot_real_profile may terminate a running browser that locks
|
||||
the profile. Destructive → default False; the agent gates it on user OK.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
if isinstance(browser_cfg, dict):
|
||||
return bool(browser_cfg.get("real_profile_autoclose", False))
|
||||
except Exception as e:
|
||||
logger.debug("could not read real_profile_autoclose: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _processes_holding_profile(src: str):
|
||||
"""Yield (psutil.Process) instances holding the user-data-dir ``src`` open.
|
||||
|
||||
Identity discipline mirrors the daemon reaper: a process qualifies only when
|
||||
it's a Chromium-family binary AND its command line references THIS
|
||||
user-data-dir — so we never terminate an unrelated same-PID process. Any
|
||||
ambiguity (unreadable cmdline) is skipped, fail-closed.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError: # hard dep; defensive
|
||||
return
|
||||
norm = os.path.normcase(os.path.normpath(src))
|
||||
browser_bins = (
|
||||
"chrome", "chrome.exe", "chromium", "chromium.exe", "chrome_crashpad",
|
||||
"brave", "brave.exe", "msedge", "msedge.exe", "google chrome",
|
||||
)
|
||||
for proc in psutil.process_iter(["name", "cmdline"]):
|
||||
try:
|
||||
name = (proc.info.get("name") or "").lower()
|
||||
cmd = proc.info.get("cmdline") or []
|
||||
joined = " ".join(cmd)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
|
||||
continue
|
||||
if not any(b in name for b in browser_bins):
|
||||
# Some platforms report a generic name; also accept when the binary
|
||||
# in argv[0] looks like a browser.
|
||||
argv0 = (cmd[0].lower() if cmd else "")
|
||||
if not any(b in argv0 for b in browser_bins):
|
||||
continue
|
||||
# Binding: the exact user-data-dir must appear in the cmdline
|
||||
# (--user-data-dir=<src>), normalized for case/separators.
|
||||
if norm not in os.path.normcase(os.path.normpath(joined)) and \
|
||||
f"--user-data-dir={src}".lower() not in joined.lower():
|
||||
continue
|
||||
yield proc
|
||||
|
||||
|
||||
def close_browser_holding_profile(src: str, timeout: float = 15.0) -> tuple[bool, str]:
|
||||
"""Terminate the browser process tree holding ``src`` and wait for release.
|
||||
|
||||
CONSENTED, DESTRUCTIVE. Only call after the user has agreed to close their
|
||||
browser — it terminates every Chromium-family process bound to this exact
|
||||
user-data-dir (graceful terminate, then kill), so unsaved tab/form state in
|
||||
that browser is lost. Returns ``(True, msg)`` once the profile lock actually
|
||||
releases, ``(False, msg)`` if processes couldn't be found/killed or the lock
|
||||
never released within ``timeout``.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return False, "psutil unavailable — cannot close the browser automatically."
|
||||
|
||||
procs = list(_processes_holding_profile(src))
|
||||
if not procs:
|
||||
# Nothing we can see holds it. Either already closed, or the holder is
|
||||
# a different user / unreadable — caller re-probes the lock.
|
||||
return False, "no matching browser process found holding the profile."
|
||||
|
||||
# Include child processes (renderers, GPU, crashpad) for a full tree kill.
|
||||
targets = []
|
||||
for p in procs:
|
||||
targets.append(p)
|
||||
try:
|
||||
targets.extend(p.children(recursive=True))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
# Graceful terminate first.
|
||||
for p in targets:
|
||||
try:
|
||||
p.terminate()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
gone, alive = psutil.wait_procs(targets, timeout=min(timeout, 8.0))
|
||||
for p in alive:
|
||||
try:
|
||||
p.kill()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
psutil.wait_procs(alive, timeout=3.0)
|
||||
|
||||
# The lock releases slightly after the process exits on Windows; poll.
|
||||
source_profile = _last_used_profile(src)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not _profile_is_locked(src, source_profile):
|
||||
return True, f"closed the browser and the profile lock released."
|
||||
time.sleep(0.5)
|
||||
return False, (
|
||||
"closed the browser processes but the profile is still locked — "
|
||||
"another instance may have relaunched (background/tray mode)."
|
||||
)
|
||||
|
||||
|
||||
def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | None, str | None]:
|
||||
"""Snapshot ``browser``'s real ACTIVE profile into the hermes copy dir.
|
||||
|
||||
@@ -691,15 +803,32 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N
|
||||
source_profile = _last_used_profile(src)
|
||||
# Fast lock probe BEFORE any copy: a running browser holds the cookie DB
|
||||
# deny-all (Windows), and a blocking file op on it can hang the launch for
|
||||
# minutes. Bail immediately with an actionable message instead. On POSIX
|
||||
# this never trips (no mandatory locking) so copy-while-running still works.
|
||||
# minutes. On POSIX this never trips (no mandatory locking) so
|
||||
# copy-while-running still works.
|
||||
if _profile_is_locked(src, source_profile):
|
||||
return None, (
|
||||
f"{browser} is running and has its profile locked, so its login "
|
||||
"data can't be copied. Fully quit the browser (including any "
|
||||
"background/tray instance) and retry, or turn "
|
||||
"browser.use_real_profile off."
|
||||
)
|
||||
# Consented auto-close: only when browser.real_profile_autoclose is on
|
||||
# (the agent must have the user's OK — it's destructive). Terminate the
|
||||
# browser tree bound to this profile, wait for the lock to release, then
|
||||
# continue the snapshot. Off by default → fail fast with guidance.
|
||||
if _real_profile_autoclose():
|
||||
closed, msg = close_browser_holding_profile(src)
|
||||
if not closed:
|
||||
return None, (
|
||||
f"{browser} is running and locks its login data; Hermes "
|
||||
f"tried to close it but {msg} Fully quit {browser} "
|
||||
"(including any background/tray instance) and retry."
|
||||
)
|
||||
logger.info("real-profile: %s", msg)
|
||||
# fall through — lock released, snapshot proceeds
|
||||
else:
|
||||
return None, (
|
||||
f"{browser} is running and has its profile locked, so its login "
|
||||
"data can't be copied. Fully quit the browser (including any "
|
||||
"background/tray instance) and retry, turn "
|
||||
"browser.use_real_profile off, or enable "
|
||||
"browser.real_profile_autoclose to let Hermes close it for you "
|
||||
"(closes the browser, losing unsaved tabs)."
|
||||
)
|
||||
marker = os.path.join(dst, _SNAPSHOT_DONE_MARKER)
|
||||
# Only a copy that previously COMPLETED counts as populated. A half-written
|
||||
# tree (no marker) is treated as absent and rebuilt — otherwise a torn first
|
||||
|
||||
@@ -592,6 +592,15 @@ DEFAULT_CONFIG = {
|
||||
# real-profile local session even under a cloud browser backend. Toggle
|
||||
# in the desktop Settings → Browser section.
|
||||
"use_real_profile": False,
|
||||
# When real-profile browsing needs the browser closed (Windows: a
|
||||
# running Chrome/Edge/Brave locks its cookie DB deny-all, so it must be
|
||||
# fully quit before its profile can be copied), allow Hermes to close
|
||||
# the browser automatically instead of just failing. DESTRUCTIVE: it
|
||||
# terminates the browser process tree bound to that profile, losing any
|
||||
# unsaved tabs/form state — so it is OFF by default and the agent must
|
||||
# get the user's OK before triggering it. No effect on macOS/Linux,
|
||||
# where the profile can be copied while the browser runs.
|
||||
"real_profile_autoclose": False,
|
||||
"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.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""LIVE Windows E2E: consented auto-close makes real-profile work with a running browser.
|
||||
|
||||
windows-latest only. Proves the end state: with browser.real_profile_autoclose
|
||||
on, a REAL running Chrome that share-locks its cookie DB is terminated by
|
||||
snapshot_real_profile, the lock releases, and a valid signed-in-shaped copy is
|
||||
produced. Also proves the default (autoclose off) fails fast, not hangs.
|
||||
|
||||
PROOF branch evidence — reverted before merge; never lands on main.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows-only live E2E")
|
||||
|
||||
_CHROME = (
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
)
|
||||
|
||||
|
||||
def _find_chrome():
|
||||
for p in _CHROME:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return shutil.which("chrome") or shutil.which("chrome.exe")
|
||||
|
||||
|
||||
def _launch_chrome_on(user_data: Path):
|
||||
proc = subprocess.Popen(
|
||||
[_find_chrome(), "--headless=new", "--disable-gpu", "--no-first-run",
|
||||
"--no-default-browser-check", f"--user-data-dir={user_data}",
|
||||
"--remote-debugging-port=0", "about:blank"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
ck = None
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline and not ck:
|
||||
for rel in (r"Default\Network\Cookies", r"Default\Cookies"):
|
||||
c = user_data / rel
|
||||
if c.is_file() and c.stat().st_size > 0:
|
||||
ck = c
|
||||
break
|
||||
time.sleep(1)
|
||||
return proc, ck
|
||||
|
||||
|
||||
def _raw_copy_fails(path: str) -> bool:
|
||||
try:
|
||||
shutil.copy2(path, path + ".rc"); os.unlink(path + ".rc"); return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def test_autoclose_off_fails_fast(tmp_path):
|
||||
"""Default (autoclose off): a running Chrome → fail fast (<30s) with the
|
||||
quit/autoclose guidance, never a hang, never a silent copy."""
|
||||
if not _find_chrome():
|
||||
pytest.skip("no chrome")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
import hermes_cli.browser_connect as bc
|
||||
|
||||
ud = tmp_path / "ud"; ud.mkdir()
|
||||
proc, ck = _launch_chrome_on(ud)
|
||||
try:
|
||||
assert ck is not None, "no cookie db"
|
||||
if not _raw_copy_fails(str(ck)):
|
||||
pytest.skip("Chrome did not share-lock on this runner")
|
||||
orig = bc.real_profile_data_dir
|
||||
bc.real_profile_data_dir = lambda b, system=None: str(ud)
|
||||
bc._real_profile_autoclose = lambda: False
|
||||
try:
|
||||
t0 = time.time()
|
||||
dst, err = bc.snapshot_real_profile("chrome", src=str(ud))
|
||||
elapsed = time.time() - t0
|
||||
finally:
|
||||
bc.real_profile_data_dir = orig
|
||||
assert dst is None and err
|
||||
assert "quit" in err.lower() and "real_profile_autoclose" in err
|
||||
assert elapsed < 30, f"hung {elapsed:.0f}s — must fail fast"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired: proc.kill()
|
||||
|
||||
|
||||
def test_autoclose_on_closes_chrome_and_snapshots(tmp_path):
|
||||
"""Consented auto-close: a running Chrome is terminated, the lock releases,
|
||||
and a valid cookie DB copy is produced — real-profile works WITH the browser
|
||||
initially running."""
|
||||
if not _find_chrome():
|
||||
pytest.skip("no chrome")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
import hermes_cli.browser_connect as bc
|
||||
|
||||
ud = tmp_path / "ud"; ud.mkdir()
|
||||
proc, ck = _launch_chrome_on(ud)
|
||||
try:
|
||||
assert ck is not None, "no cookie db"
|
||||
if not _raw_copy_fails(str(ck)):
|
||||
pytest.skip("Chrome did not share-lock on this runner")
|
||||
|
||||
orig = bc.real_profile_data_dir
|
||||
bc.real_profile_data_dir = lambda b, system=None: str(ud)
|
||||
bc._real_profile_autoclose = lambda: True
|
||||
# Isolate the snapshot store under tmp.
|
||||
orig_home = bc.get_hermes_home
|
||||
bc.get_hermes_home = lambda: tmp_path / "hh"
|
||||
try:
|
||||
dst, err = bc.snapshot_real_profile("chrome", src=str(ud))
|
||||
finally:
|
||||
bc.real_profile_data_dir = orig
|
||||
bc.get_hermes_home = orig_home
|
||||
|
||||
assert err is None, f"auto-close snapshot failed: {err}"
|
||||
assert dst is not None
|
||||
copy_ck = Path(dst) / "Default" / "Cookies"
|
||||
assert copy_ck.is_file(), "cookie DB not copied after auto-close"
|
||||
# Valid SQLite with the cookies table.
|
||||
con = sqlite3.connect(str(copy_ck))
|
||||
try:
|
||||
names = {r[0] for r in con.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
finally:
|
||||
con.close()
|
||||
assert "cookies" in names, f"copied DB missing cookies table: {names}"
|
||||
# The original Chrome should now be gone (we terminated its tree).
|
||||
assert proc.poll() is not None, "auto-close did not terminate Chrome"
|
||||
finally:
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate(); proc.wait(timeout=15)
|
||||
except Exception:
|
||||
try: proc.kill()
|
||||
except Exception: pass
|
||||
@@ -745,6 +745,7 @@ class TestReviewRound3:
|
||||
home = tmp_path / "hh"
|
||||
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(bc, "_profile_is_locked", lambda s, p: True)
|
||||
monkeypatch.setattr(bc, "_real_profile_autoclose", lambda: False)
|
||||
called = {"copytree": 0}
|
||||
import shutil as _sh
|
||||
orig_ct = _sh.copytree
|
||||
@@ -754,8 +755,76 @@ class TestReviewRound3:
|
||||
assert dst is None
|
||||
assert err and ("locked" in err.lower() or "running" in err.lower())
|
||||
assert "quit" in err.lower()
|
||||
assert "real_profile_autoclose" in err # offers the consented option
|
||||
assert called["copytree"] == 0 # bailed before any copy
|
||||
|
||||
def test_autoclose_closes_then_snapshots(self, tmp_path, monkeypatch):
|
||||
"""With autoclose consent on, a locked profile is closed and the
|
||||
snapshot then proceeds (lock released)."""
|
||||
import hermes_cli.browser_connect as bc
|
||||
src = self._multi(tmp_path / "real")
|
||||
home = tmp_path / "hh"
|
||||
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
|
||||
# Locked on first probe; closer "releases" it; subsequent copy runs.
|
||||
lock_state = {"locked": True}
|
||||
monkeypatch.setattr(bc, "_profile_is_locked",
|
||||
lambda s, p: lock_state["locked"])
|
||||
monkeypatch.setattr(bc, "_real_profile_autoclose", lambda: True)
|
||||
|
||||
def fake_close(src_, timeout=15.0):
|
||||
lock_state["locked"] = False
|
||||
return True, "closed the browser and the profile lock released."
|
||||
|
||||
monkeypatch.setattr(bc, "close_browser_holding_profile", fake_close)
|
||||
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
|
||||
assert err is None
|
||||
assert (home / "browser-profile" / "chrome" / "Default" / "Cookies").read_text() == "PROFILE6-SESSION"
|
||||
|
||||
def test_autoclose_failure_reports_clearly(self, tmp_path, monkeypatch):
|
||||
"""If autoclose can't release the lock (relaunch/tray), fail with a
|
||||
clear message and no copy."""
|
||||
import hermes_cli.browser_connect as bc
|
||||
src = self._multi(tmp_path / "real")
|
||||
home = tmp_path / "hh"
|
||||
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(bc, "_profile_is_locked", lambda s, p: True)
|
||||
monkeypatch.setattr(bc, "_real_profile_autoclose", lambda: True)
|
||||
monkeypatch.setattr(bc, "close_browser_holding_profile",
|
||||
lambda s, timeout=15.0: (False, "still locked — relaunched."))
|
||||
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
|
||||
assert dst is None
|
||||
assert err and "tried to close it" in err
|
||||
|
||||
def test_processes_holding_profile_identity_binding(self, tmp_path, monkeypatch):
|
||||
"""The process matcher requires BOTH a browser binary AND this exact
|
||||
user-data-dir in the cmdline — never a same-name process on another dir."""
|
||||
import hermes_cli.browser_connect as bc
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self, name, cmdline):
|
||||
self.info = {"name": name, "cmdline": cmdline}
|
||||
|
||||
ud = str(tmp_path / "ud")
|
||||
procs = [
|
||||
FakeProc("chrome.exe", ["chrome.exe", f"--user-data-dir={ud}"]), # match
|
||||
FakeProc("chrome.exe", ["chrome.exe", "--user-data-dir=C:\\Other"]), # wrong dir
|
||||
FakeProc("python.exe", ["python.exe", f"--user-data-dir={ud}"]), # not a browser
|
||||
]
|
||||
|
||||
class FakePsutil:
|
||||
NoSuchProcess = psutil_exc = type("E", (Exception,), {})
|
||||
AccessDenied = type("E2", (Exception,), {})
|
||||
|
||||
def process_iter(self, attrs=None):
|
||||
return iter(procs)
|
||||
|
||||
import sys as _sys
|
||||
monkeypatch.setitem(_sys.modules, "psutil", FakePsutil())
|
||||
matched = list(bc._processes_holding_profile(ud))
|
||||
assert len(matched) == 1
|
||||
assert matched[0].info["name"] == "chrome.exe"
|
||||
assert f"--user-data-dir={ud}" in " ".join(matched[0].info["cmdline"])
|
||||
|
||||
def test_consent_off_triggers_cleanup(self, tmp_path, monkeypatch):
|
||||
import tools.browser_tool as bt
|
||||
called = {"n": 0}
|
||||
|
||||
@@ -195,6 +195,12 @@ therefore requires the browser **fully quit**, including any background/tray
|
||||
instance (Chrome's "continue running background apps when closed" keeps a
|
||||
`chrome.exe` alive after you close the window). macOS and Linux can copy the
|
||||
profile while the browser is running.
|
||||
|
||||
Set `browser.real_profile_autoclose: true` to let Hermes **close the browser
|
||||
for you** when it's holding the profile — it terminates the browser process
|
||||
tree bound to that profile, waits for the lock to release, then continues. This
|
||||
is destructive (you lose unsaved tabs/form state in that browser), so it is
|
||||
**off by default** and the agent asks before doing it.
|
||||
:::
|
||||
|
||||
- **Supported browsers:** Chrome, Edge, Brave, Chromium (whichever is your OS
|
||||
|
||||
Reference in New Issue
Block a user