feat(browser): close-with-approval flow for Windows real-profile (toggle arms, agent asks, blocked if still locked) [proof do-not-merge]

Refines the Windows path per three requirements:
1. Only when the toggle is set — closing is offered only if
   browser.real_profile_autoclose is on.
2. Blocked when locked — snapshot_real_profile NEVER kills; a locked profile
   always returns the [profile-locked] signal and the copy is refused. A later
   attempt that is still locked blocks again (no loop, no auto-kill).
3. Ask approval to close — closing is an explicit, user-approved step:
    (new CLI subcommand) runs
   close_browser_holding_profile only when the agent has the user's OK. The
   locked error tells the agent to ask first, then run it, then retry.

- browser_connect: snapshot blocks with _PROFILE_LOCKED_PREFIX (autoclose-armed
  message offers the close; off message says fully-quit); no in-snapshot kill.
- main.py:  subcommand (identity+binding-verified
  tree kill via close_browser_holding_profile); added to _BUILTIN_SUBCOMMANDS.
- browser_tool: surfaces the locked signal + the exact approved-close command.
- Docs/config: toggle arms + agent asks + blocked-if-still-locked.

Tests: snapshot blocks-not-kills with autoclose on AND off; process matcher
identity/binding. 73 real-profile tests pass. Windows live E2E (proof): locked
blocks fast without killing → approved close terminates Chrome → snapshot then
copies a valid DB; autoclose-off blocks with quit guidance.
This commit is contained in:
Teknium
2026-08-26 19:10:21 -07:00
parent 00d5632249
commit e4451ec6e5
8 changed files with 339 additions and 60 deletions
@@ -0,0 +1,62 @@
name: Windows real-profile live E2E
# ON-DEMAND PROOF (real-profile browsing, PR #95620 — Windows close-with-approval).
#
# Proves on a REAL windows-latest runner that a running Chrome holding the
# profile makes snapshot BLOCK (never kill/hang), the explicit approved close
# then terminates Chrome and releases the lock, and snapshot then succeeds.
#
# 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 close-with-approval 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 close-with-approval 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
+25 -18
View File
@@ -629,6 +629,11 @@ def _mirror_profile_auth(src: str, dst: str, source_profile: str) -> int:
_SNAPSHOT_DONE_MARKER = ".hermes-snapshot-complete"
# Prefix stamped on the "profile is locked" error so the calling layer can
# recognize it as the specific needs-the-browser-closed condition (vs a generic
# snapshot failure) and surface the close-with-approval flow.
_PROFILE_LOCKED_PREFIX = "[profile-locked] "
def _profile_cookie_db(src: str, source_profile: str) -> str | None:
"""Path to the active profile's cookie DB (modern Network/ first)."""
@@ -806,29 +811,31 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N
# minutes. On POSIX this never trips (no mandatory locking) so
# copy-while-running still works.
if _profile_is_locked(src, source_profile):
# 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.
# NEVER kill from here. Closing the user's browser is destructive and
# must be an explicit, per-attempt, user-approved step — not a silent
# side effect of a snapshot. So we always BLOCK when locked and let the
# agent decide whether to ask the user to close it (only offered when
# browser.real_profile_autoclose arms the capability). A subsequent
# attempt that is still locked blocks again — no auto-retry, no loop.
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
msg = (
f"{browser} is running and has its profile locked, so its login "
"data can't be copied yet. Hermes can close it for you "
"(this quits the browser — you'll lose unsaved tabs). Ask the "
"user to confirm, then close it and retry; if it's still locked "
"after that, they must fully quit it (including any "
"background/tray instance)."
)
else:
return None, (
msg = (
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)."
"background/tray instance) and retry, or turn "
"browser.use_real_profile off. (Enable "
"browser.real_profile_autoclose to let Hermes offer to close it "
"for you.)"
)
return None, _PROFILE_LOCKED_PREFIX + msg
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
+7 -6
View File
@@ -594,12 +594,13 @@ DEFAULT_CONFIG = {
"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.
# fully quit before its profile can be copied), arm the "offer to close
# it" flow. This does NOT auto-kill: when the profile is locked the
# snapshot always blocks and the agent asks the user first; only on
# approval does it run `hermes browser close-profile` (terminates the
# browser process tree bound to that profile, losing unsaved tabs) and
# 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,
"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=...)
+56
View File
@@ -12351,6 +12351,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
"browser",
"verify",
# Help-ish invocations — plugin commands not being listed in
# top-level --help is an acceptable trade-off for skipping an
@@ -13248,6 +13249,61 @@ def main():
worktree_parser.set_defaults(func=_dispatch_worktree)
# =========================================================================
# browser command — real-profile helpers (agent-invoked, user-approved)
# =========================================================================
browser_parser = subparsers.add_parser(
"browser",
help="Real-profile browsing helpers (close a browser locking its profile)",
description=(
"Helpers for real-profile browsing (browser.use_real_profile). "
"close-profile terminates the browser process tree holding your "
"default profile so Hermes can copy it — DESTRUCTIVE (unsaved tabs "
"in that browser are lost). The agent runs this only after you "
"approve closing the browser."
),
)
browser_subparsers = browser_parser.add_subparsers(dest="browser_action")
browser_close = browser_subparsers.add_parser(
"close-profile",
help="Close the browser locking your real profile (asks nothing — "
"run only with the user's explicit OK; loses unsaved tabs)",
)
browser_close.add_argument(
"--browser",
help="Override detected default browser (chrome/edge/brave/chromium)",
)
def _dispatch_browser(_args):
from hermes_cli.browser_connect import (
UNSUPPORTED_CHANNEL,
close_browser_holding_profile,
detect_default_chromium,
real_profile_data_dir,
)
action = getattr(_args, "browser_action", None)
if action != "close-profile":
browser_parser.print_help()
return 2
browser = getattr(_args, "browser", None) or detect_default_chromium()
if not browser or browser == UNSUPPORTED_CHANNEL:
print("✗ No supported Chromium default browser detected.", file=sys.stderr)
return 1
src = real_profile_data_dir(browser)
if not src:
print(f"✗ Could not resolve the {browser} profile directory.", file=sys.stderr)
return 1
closed, msg = close_browser_holding_profile(src)
if closed:
print(f"✓ {msg}")
return 0
print(f"✗ {msg}", file=sys.stderr)
return 1
browser_parser.set_defaults(func=_dispatch_browser)
# =========================================================================
# secrets command — external secret managers (Bitwarden, 1Password)
# =========================================================================
@@ -0,0 +1,156 @@
"""LIVE Windows E2E: locked-profile blocks; explicit approved close then works.
windows-latest only. Proves the three-part contract:
1. A running Chrome that share-locks its profile makes snapshot_real_profile
BLOCK (never kill, never hang) with the [profile-locked] signal.
2. The explicit, user-approved close step (close_browser_holding_profile,
what `hermes browser close-profile` runs) terminates the browser and the
lock releases.
3. After the close, snapshot_real_profile succeeds and copies a valid DB.
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_locked_blocks_then_approved_close_then_snapshots(tmp_path):
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_dd = bc.real_profile_data_dir
orig_home = bc.get_hermes_home
bc.real_profile_data_dir = lambda b, system=None: str(ud)
bc.get_hermes_home = lambda: tmp_path / "hh"
# autoclose armed → message OFFERS the close, but snapshot must NOT kill.
bc._real_profile_autoclose = lambda: True
try:
# 1. Locked → blocks fast with the [profile-locked] signal, no kill.
t0 = time.time()
dst, err = bc.snapshot_real_profile("chrome", src=str(ud))
elapsed = time.time() - t0
assert dst is None and err
assert err.startswith(bc._PROFILE_LOCKED_PREFIX), f"not the locked signal: {err}"
assert elapsed < 30, f"blocked slowly ({elapsed:.0f}s) — must be fast"
assert proc.poll() is None, "snapshot must NOT have killed Chrome on its own"
# 2. Explicit approved close (the engine `hermes browser
# close-profile` runs) terminates Chrome; lock releases.
closed, msg = bc.close_browser_holding_profile(str(ud))
assert closed, f"approved close failed: {msg}"
assert proc.poll() is not None, "close did not terminate Chrome"
# 3. Snapshot now succeeds with a valid cookie DB.
dst2, err2 = bc.snapshot_real_profile("chrome", src=str(ud))
assert err2 is None, f"post-close snapshot failed: {err2}"
assert dst2 is not None
cands = [Path(dst2) / "Default" / "Network" / "Cookies",
Path(dst2) / "Default" / "Cookies"]
copy_ck = next((c for c in cands if c.is_file()), None)
assert copy_ck is not None, (
"cookie DB not copied after approved close; Default: "
+ repr(sorted(os.listdir(Path(dst2) / "Default"))
if (Path(dst2) / "Default").is_dir() else "NONE"))
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"missing cookies table: {names}"
finally:
bc.real_profile_data_dir = orig_dd
bc.get_hermes_home = orig_home
finally:
try:
if proc.poll() is None:
proc.terminate(); proc.wait(timeout=15)
except Exception:
try: proc.kill()
except Exception: pass
def test_autoclose_off_blocks_with_quit_guidance(tmp_path):
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:
dst, err = bc.snapshot_real_profile("chrome", src=str(ud))
assert dst is None and err and err.startswith(bc._PROFILE_LOCKED_PREFIX)
assert "quit" in err.lower()
assert proc.poll() is None, "must not kill Chrome when autoclose off"
finally:
bc.real_profile_data_dir = orig
finally:
proc.terminate()
try: proc.wait(timeout=15)
except subprocess.TimeoutExpired: proc.kill()
+12 -31
View File
@@ -738,8 +738,8 @@ class TestReviewRound3:
assert bc._profile_is_locked(str(tmp_path), "Default") is True
def test_snapshot_fails_fast_when_locked(self, tmp_path, monkeypatch):
"""snapshot_real_profile bails with the actionable message when the
active profile's cookie DB is locked — never proceeds to a heavy copy."""
"""snapshot_real_profile always BLOCKS when locked — never kills, never
proceeds to a heavy copy. autoclose off → plain quit guidance."""
import hermes_cli.browser_connect as bc
src = self._multi(tmp_path / "real")
home = tmp_path / "hh"
@@ -753,47 +753,28 @@ class TestReviewRound3:
lambda *a, **k: (called.__setitem__("copytree", called["copytree"] + 1), orig_ct(*a, **k))[1])
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert dst is None
assert err and ("locked" in err.lower() or "running" in err.lower())
assert err and err.startswith(bc._PROFILE_LOCKED_PREFIX)
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."""
def test_snapshot_blocks_when_locked_even_with_autoclose(self, tmp_path, monkeypatch):
"""Even with autoclose armed, snapshot_real_profile does NOT kill — it
blocks and defers the close to the explicit, user-approved step. The
message offers the close (mentions Hermes can close it)."""
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)
killed = {"n": 0}
monkeypatch.setattr(bc, "close_browser_holding_profile",
lambda s, timeout=15.0: (False, "still locked — relaunched."))
lambda *a, **k: (killed.__setitem__("n", killed["n"] + 1), (True, "x"))[1])
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert dst is None
assert err and "tried to close it" in err
assert err and err.startswith(bc._PROFILE_LOCKED_PREFIX)
assert "close it for you" in err.lower() or "can close it" in err.lower()
assert killed["n"] == 0 # snapshot must NOT invoke the killer itself
def test_processes_holding_profile_identity_binding(self, tmp_path, monkeypatch):
"""The process matcher requires BOTH a browser binary AND this exact
+13
View File
@@ -1635,6 +1635,19 @@ def _real_profile_cdp() -> tuple:
# No live browser owns the dir now — safe to (re)snapshot + overlay.
snap_dir, err = snapshot_real_profile(browser)
if err or not snap_dir:
from hermes_cli.browser_connect import _PROFILE_LOCKED_PREFIX
if err and err.startswith(_PROFILE_LOCKED_PREFIX):
# The user's browser is holding the profile. Surface the guidance
# verbatim (it already tells the agent whether closing is armed)
# plus the exact approved-close command. The agent must ASK the
# user before running it — it quits their browser.
body = err[len(_PROFILE_LOCKED_PREFIX):]
return None, (
body + " To close it (only after the user approves — it "
"quits their browser and loses unsaved tabs), run: "
"`hermes browser close-profile`, then retry."
)
return None, f"browser.use_real_profile is on, but {err}"
copy_dir = snap_dir
+8 -5
View File
@@ -196,11 +196,14 @@ 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.
Set `browser.real_profile_autoclose: true` to let Hermes **offer to close the
browser for you** when it's holding the profile. Even with this on, Hermes never
closes it automatically — when the profile is locked it always stops and the
agent asks you first; only on your approval does it run `hermes browser
close-profile` (terminates the browser process tree bound to that profile,
losing unsaved tabs), then retries. If the profile is still locked after that
(e.g. a background/tray instance relaunched), Hermes stays blocked and tells you
to fully quit the browser — it won't loop or kill again on its own.
:::
- **Supported browsers:** Chrome, Edge, Brave, Chromium (whichever is your OS