↓ 跳过正文

给 Agent 一台专用浏览器:独立 user-data-dir + CDP 常驻实例,绕开真实 profile 的独占锁

目录

本篇处理两个症状:Agent 一开网页,Windows 就弹出「你要如何打开这个文件?」;开启"使用真实浏览器 profile"后,只要你自己的浏览器开着,Agent 每次调浏览器都报错。适用环境:Windows 11(shell 为 git-bash)、Brave 主力浏览器(本机未装 Chrome)、Hermes 桌面版 0.21.x,时间跨度 2026-08-31 至 2026-09-25,上游源码核对到 v0.21.3(2026-09-16)。结论:给 Agent 单独起一个独立 --user-data-dir、固定 CDP 端口的常驻实例,配置里用 browser.cdp_url 附着过去,不用内置的真实 profile 方案。

一、症状与第一手数据
#

第一个症状是弹出"打开方式"对话框,问 chrome 用什么程序打开。触发点在 browser_harness/admin.py::_launch_browser() 的 Windows 分支:

subprocess.Popen(["cmd", "/c", "start", "", win_target or "chrome"] + profile_args)

win_target 来自 _browser_launch_spec(),没有匹配的 profile 目录时回落为 "chrome"。App Paths 里没有 chrome.exe,start 就把 chrome 当成没有关联程序的文件。这条路径只在 daemon 必须拉起浏览器、而 _chrome_running()(用 tasklist 查 chrome.exe / msedge.exe / helium.exe)没找到在跑的浏览器时才会走到。

平时 browser-harness 是附着优先:supported_browser_running() 扫描 chrome / msedge / brave / chromium / helium,任一在跑就附着;switch_tab(target, activate=False) 默认不切到前台。

处置是修名字解析:把 HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe 的(默认)值和 Path 指向 brave.exe,再把用户级环境变量 BH_CHROME_PATH、CHROME_PATH 也指向 brave.exe。2026-09-25 复核,这四处都没有回退。

第二个症状才是真正的痛点:browser.use_real_profile: true 时,Brave 开着就每次失败。在 Brave 开着、活动 profile 为 Default 的状态下逐文件探测,结果如下:

profile / 文件 状态 备注
Default Network\Cookies LOCKED(PermissionError) 只锁当前这一份
Default Login Data READABLE 只有 Cookies 库被独占
Profile 1 / 2 / 3 READABLE 日常使用时可读

关键发现:一个 profile 只要在本次 Brave 运行期间被加载过,它的 Cookies 库就一直锁着,直到 Brave 完全退出。关掉该 profile 的窗口、等 45 秒、把 last_used 切走,都不会释放。

二、根因
#

根因是四件事叠在一起:

  1. Windows 上,运行中的浏览器独占活动 profile 的 Network\Cookies。这是操作系统层面的限制。
  2. 内置 real-profile 在 Windows 上的做法是:把活动 profile 复制一份快照,再用真实浏览器二进制以 headless 方式起在副本上。复制时正好撞上这把锁。
  3. 出错时 fail-closed,不降级。
  4. 快照会把真实凭据复制到另一个目录,登录态因此多出一份拷贝。

fail-closed 那一段在 tools/browser_tool_session.py::_create_local_session 开头:

if allow_real_profile:
    cdp_url, err = _real_profile._real_profile_cdp()
    if err:
        raise RuntimeError(err)

docstring 写明:“Real-profile fails closed on resolver/launch errors (a consented user must never be silently downgraded to a throwaway)"。也就是说,一出错就硬报错,不会退到匿名浏览器。

三、为什么不选内置的那几条路
#

选项 机制 代价 判定
只关 use_real_profile(匿名档) 每次用一次性的干净 profile 没有登录态;没装自带 Chromium 时直接报 Auto-launch failed: Chrome not found 不够用
装自带 Chromium(约 150 MB) 让匿名档能跑起来 对 real-profile 毫无补救(fail-closed 不降级) 解决不了问题
use_real_profile: true + real_profile_pin 指向不常用的 profile 快照复制该 profile 再 headless 起 你哪天在那个 profile 登录一次,就得彻底退出浏览器才能恢复;凭据仍会被复制 不稳
/browser connect Hermes 自己拉起带调试端口的浏览器 斜杠命令只能由用户输入;BROWSER_CDP_URL 只在当前进程有效 不适合 Agent 自用
云后端(Browser Use cloud) 浏览器跑在云上 登录态与私网访问都不合适 不选

另外,real-profile 开启时 browser.engine 会被忽略。

四、配置(可抄的做法)
#

配置用命令改,不要手改 config.yaml:

hermes config set browser.use_real_profile false
hermes config set browser.cdp_url http://127.0.0.1:9222

实例启动参数:

brave.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\hermes\chrome-debug" --no-first-run --no-default-browser-check --no-startup-window
参数 作用
--remote-debugging-port=9222 固定 CDP 端口
--user-data-dir=...chrome-debug 独立数据目录,与日常 profile 分开
--no-first-run / --no-default-browser-check 跳过首次运行引导和默认浏览器询问
--no-startup-window 启动时不开窗口,静默常驻

其余组件:

组件 内容
开机自启 在 %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\ 放 Hermes-Browser-Debug.lnk,目标直接指向 brave.exe,参数同上。不经过 .cmd,避免控制台闪窗
手动启动 / 修复 %LOCALAPPDATA%\hermes\bin\hermes-browser.cmd:先探测 127.0.0.1:9222 是否 LISTENING;已在跑就什么都不做(幂等)
收尾 hermes-browser-reset.cmd + hermes-browser-reset.py,参数 --list / --keep-blank / --port N / --stop。走 DevTools 的 HTTP /json/* 端点,不需要 websocket 客户端
说明文档 %LOCALAPPDATA%\hermes\bin\README-hermes-browser.md

需要在这个实例里登录某个站点时,用同一个 --user-data-dir、但不带 --no-startup-window 起一次 Brave:命令会被转发进那个静默实例并弹出一个窗口,登录完关掉窗口即可,实例继续静默运行并带着这份登录态。这些 cookie 不与日常 profile 同步,站点会话过期后要重新登录一次。

早期为了试"专用 profile"建过一个 Profile 4。删掉目录后,Brave 的 Local State 里 info_cache 仍留着这一条。Brave 运行时把 Local State 读在内存里,退出时整体回写,直接改文件会被覆盖。在 Brave 的"管理个人资料"里删掉这一项即可。

五、踩过的坑
#

  1. .cmd 里不能写中文注释。cmd.exe 按 OEM 代码页(本机 cp936/GBK)读批处理,UTF-8 中文会被拆成乱字节当命令执行,报出一串 'l' 不是内部或外部命令。.cmd 一律纯 ASCII,中文说明写到 .md 里。
  2. start "" app.exe 起的 GUI 进程会继承控制台句柄。从 PowerShell(& x.cmd)或任何捕获管道的调用方执行时,调用方会一直等到这个 GUI 进程退出,实测卡到 180 秒超时,而脚本其实已经成功。修法:start 行加 >nul 2>&1,调用方改用 Start-Process -FilePath x.cmd。
  3. Start-Process -ArgumentList '--a="x y"' 的引号可能被吞,曾把静默实例起成带窗口的实例。复杂参数先固化在 .cmd / .lnk 里再调用。
  4. 桌面版会自己改配置。2026-09-19 14:47:05,use_real_profile 被写成 true(14:17 的备份里还是 false),cdp_url 没动。写入者是"内置浏览器面板"首次打开时弹出的同意框(real-profile-consent-dialog.tsx):点"启用"会走 PUT /api/config 直接写入。因为配了 cdp_url,当下没有影响;但一旦 cdp_url 被清掉、换机器或换 profile,就会退回快照路径。处置:再执行一次 hermes config set browser.use_real_profile false,以后在这个同意框点"不再显示”。
  5. 上游改动没有修好 real-profile(核对到 v0.21.3)。锁、快照加 headless、fail-closed 都没变,锁住时一律报 [profile-locked]。变化有两处:报错会点名是哪个 auth DB、为什么锁;新增 browser.real_profile_autoclose(默认关),开启后会跑 hermes browser close-profile。这个命令按 user-data-dir 杀掉持有 profile 的进程树,不区分是谁起的,你的日常浏览器也会被关掉。保持默认关,绝不主动跑。
  6. 快照路径自动起的浏览器(命令行里带 hermes\browser-profile\brave)会一直活着,锁着快照目录。清理时按命令行精确匹配再杀,别误杀日常浏览器。
  7. cdp_url 不通时不会回退。_resolve_cdp_override 在端点不可达时返回原值,结果是 30 秒硬超时。实例必须开机自启、常驻;实例被关掉就是要修的故障。
  8. 别用 Agent 的后台终端起浏览器。那个任务会一直标记 running,直到浏览器退出;kill 它就等于关掉浏览器。要脱离托管,用 PowerShell Start-Process -FilePath ...。
  9. Agent 退出时不关标签页,实测连跑两轮留下 4 个。运行中用 harness 自带的 close_tab() 关掉不再用的标签,收工时跑 hermes-browser-reset.cmd。
  10. 多个任务共用一个浏览器后端会互相干扰,表现为莫名重启、弹出"要恢复页面吗?"。用 browser_exec(session="名字") 让每个任务有独立的 daemon。browser.inactivity_timeout 只负责回收空闲 daemon,不会关浏览器或标签页。
  11. 零标签是首选空闲态。关掉最后一个标签,实例不会退出,CDP 仍返回 200;只要有一个标签,就会生成一个窗口(Browser.getWindowForTarget → windowState: normal),任务栏上出现占位。不要把"留一个空白标签"当默认做法。
  12. 安全边界:CDP 只绑 127.0.0.1;没有 CORS 头;Host 不是 IP/localhost 时返回 500;带网页 Origin 的 WebSocket 握手返回 403(因为没传 --remote-allow-origins)。网页拿不到这个端口。但 CDP 没有鉴权,本机任何进程都能用 Network.getAllCookies 读走这个 profile 的全部 cookie。只把它当自动化专用身份,别在里面登录高价值账号。
  13. 别装 1Password 之类的浏览器扩展。改用内置 vault 工具链:browser_vault_list → 填标识符 → browser_vault_fill → 2FA 用 browser_vault_enter_code。它依赖 CDP 端点(_attach_vault_supervisor),专用实例正好满足。装扩展等于把密码库搬进一个无鉴权的 profile;无窗口实例里扩展 UI 也难以操作,解锁要主密码,自动化会断。

六、验证:怎么确认生效、怎么判断没生效
#

以下数据来自 2026-09-10 实测和 2026-09-25 复核:

检查项 预期
自启后就绪时间 CDP 约 0.3 秒就绪,桌面上不出现窗口
重复运行快捷方式 不起第二个实例、不弹窗(命令转发给已有实例,等于空操作)
hermes-browser.cmd 实例在跑时输出 already running 并返回;实例被杀后能重新拉起
端到端 browser_exec 附着到专用实例,能读到 DOM;你自己的 Brave 窗口没被碰
空闲开销 5 个进程 / 约 243 MB
%LOCALAPPDATA%\hermes\browser-profile\ 不存在(说明没走 real-profile)
/json/version Chrome/153.0.8010.53,UA 不含 HeadlessChrome,标签页数 0
reset --stop CDP 不通,实例进程数为 0

没生效的迹象:

  • 每次浏览器调用都 30 秒超时:实例没在跑。
  • 实例带着窗口起来:参数被拆坏了(见坑 3)。
  • 出现快照目录,或日常浏览器被碰:use_real_profile 又被写回 true 了(见坑 4)。

七、留给后来者的清单
#

  • 只装了 Brave:先把 App Paths 和 BH_CHROME_PATH / CHROME_PATH 指向 brave.exe。
  • 换机器部署时,把 %LOCALAPPDATA%\hermes\bin\ 下的 hermes-browser.cmd、hermes-browser-reset.cmd、hermes-browser-reset.py、README-hermes-browser.md 一起复制过去,再建自启 .lnk,最后执行两条 hermes config set。
  • 实例要常驻、开机自启;别用后台终端起浏览器。
  • 收工清空标签,零标签就是空闲态。
  • 需要真实身份诊断时(例如某站图片加载卡顿),先关掉日常浏览器,征得同意后用真实 profile 起一个临时实例。
  • 专用实例里不登录高价值账号,不装密码管理扩展。
  • 桌面版弹出 real-profile 同意框时,点"不再显示"。real_profile_autoclose 保持关闭。

临时实例比专用实例多一个 --remote-allow-origins=*:

brave.exe --remote-debugging-port=9223 --remote-allow-origins=* --no-first-run --no-default-browser-check <目标页>

测量前先确认标签页可见。标签页隐藏或最小化时,document.visibilityState === 'hidden',Chrome 会降低请求优先级,懒加载图片干脆不触发,未完成的请求会显示为"0 字节挂起 30 秒以上"。按这种数据下结论会出错,这次就因此误判过两轮。诊断结束后还原现场:脚本开关复位、标签页导航回原页、窗口恢复最大化、关掉调试端口,并在报告里写明。

八、碎碎念
#

这套做法的核心是把自动化身份和人的身份分开:日常浏览器归你,专用实例归 Agent,两者之间只有一条走本机回环的 CDP 通道。真要用到你的登录态,就临时、明确地开一次,用完还原,不作为默认。

内置方案出错就硬报错,这不是 bug,是有意的设计:它宁可中断自动化,也不会在你已经同意的情况下,悄悄把真实 profile 换成匿名档。明白这一点,就没必要跟它较劲,给它一条不会出错的路就行。

按本站惯例交代生成过程:正文由 deepseek-v4-pro 与 gemini-3.8-flash 两稿分头起草,claude-opus-5-5 逐条审校后合并定稿;事实、数字与命令以本机实测输出和源码原文为准。

hermes-browser.cmd(幂等启动脚本,30 行)
@echo off
REM ===========================================================================
REM  Hermes dedicated automation browser (silent, always-on)
REM  Used by Hermes browser.cdp_url -- fully isolated from your daily Brave.
REM  profile : %LOCALAPPDATA%\hermes\chrome-debug
REM  CDP port: 127.0.0.1:9222
REM
REM  To log in to a site: launch brave.exe with the SAME --user-data-dir but
REM  WITHOUT --no-startup-window -- that forwards into this instance and opens
REM  a window. Log in, close the window; the instance stays up, logged in.
REM  To stop: kill brave.exe processes whose command line mentions chrome-debug.
REM
REM  NOTE: keep this file ASCII-only. cmd.exe reads .cmd in the OEM codepage
REM        (cp936 here), so UTF-8 CJK comments get mangled into stray commands.
REM ===========================================================================
setlocal
set "EXE=C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe"
set "UD=%LOCALAPPDATA%\hermes\chrome-debug"

netstat -ano | findstr "127.0.0.1:9222" | findstr "LISTENING" >nul 2>&1
if %errorlevel%==0 (
    echo [hermes-browser] already running on 127.0.0.1:9222 - nothing to do.
    exit /b 0
)

echo [hermes-browser] starting silent instance...
REM  Redirection matters: a detached GUI child that inherits the console handles
REM  makes a PowerShell caller (or any piped caller) wait forever.
start "" "%EXE%" --remote-debugging-port=9222 --user-data-dir="%UD%" --no-first-run --no-default-browser-check --no-startup-window >nul 2>&1
exit /b 0
hermes-browser-reset.py(收工清标签 / 停实例,200 行)
"""Clear the dedicated Hermes browser instance (browser.cdp_url) and leave it running.

Usage:
    hermes-browser-reset.cmd                 close every tab (idle = no window, no taskbar entry)
    hermes-browser-reset.cmd --list          only print the current tabs
    hermes-browser-reset.cmd --keep-blank    keep one about:blank instead of closing all
    hermes-browser-reset.cmd --port N        target another CDP port (default 9222)

Why this exists: Hermes ATTACHES to this browser via browser.cdp_url, so it never
owns the process and never closes tabs it opened - a finished run leaves them
behind. This clears them over the DevTools HTTP endpoint (no websocket client).

Closing the LAST tab is safe HERE: the instance launches with --no-startup-window,
so it stays alive (and window-less) with zero tabs - and that window-less state is
exactly what removes its taskbar entry. The script verifies the endpoint
afterwards and, if a future Chromium ever does exit on last-window-close,
relaunches it through hermes-browser.cmd. Console output is ASCII on purpose
(cmd.exe reads batch and console text with the OEM code page).
"""

import argparse
import contextlib
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

TIMEOUT = 3.0


def _http(url: str, method: str = "GET") -> tuple[int, str]:
    req = urllib.request.Request(url, method=method)
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            return resp.status, resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")
    except Exception as e:  # noqa: BLE001 - connection refused, reset, etc.
        return 0, str(e)


def _json(url: str, method: str = "GET"):
    code, body = _http(url, method)
    if code != 200:
        return None
    try:
        return json.loads(body)
    except ValueError:
        return None


def _tabs(base: str):
    data = _json(base + "/json/list") or []
    return [t for t in data if t.get("type") == "page" and t.get("id")]


def _close(base: str, target_id: str) -> bool:
    for method in ("GET", "PUT"):
        code, _ = _http(f"{base}/json/close/{target_id}", method)
        if code == 200:
            return True
    return False


def _alive(base: str, seconds: float) -> bool:
    deadline = time.monotonic() + seconds
    while True:
        if _json(base + "/json/version"):
            return True
        if time.monotonic() >= deadline:
            return False
        time.sleep(0.5)


def _relaunch() -> None:
    """Start the instance again through its own script (detached, never waited on)."""
    start_cmd = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hermes-browser.cmd")
    if not os.path.exists(start_cmd):
        print(f"! start script not found: {start_cmd}")
        return
    print("... relaunching through hermes-browser.cmd")
    args = ["powershell", "-NoProfile", "-Command", f"Start-Process -FilePath '{start_cmd}'"]
    if os.name == "nt":
        flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
            subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
        subprocess.Popen(args, creationflags=flags, close_fds=True,
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                         stdin=subprocess.DEVNULL)
    else:
        subprocess.Popen(args, start_new_session=True, stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)


def _profile_dir() -> str:
    return os.path.join(os.environ.get("LOCALAPPDATA", ""), "hermes", "chrome-debug")


def _stop_instance() -> int:
    """Kill only the processes whose command line carries THIS profile dir."""
    target = os.path.normcase(os.path.normpath(_profile_dir()))
    if not target:
        print("X cannot resolve the profile dir (LOCALAPPDATA unset).")
        return 1
    try:
        import psutil
    except ImportError:
        print("X psutil is unavailable (run this through the Hermes venv python).")
        return 1
    victims = []
    for proc in psutil.process_iter(["name", "cmdline"]):
        try:
            name = (proc.info.get("name") or "").lower()
            joined = os.path.normcase(os.path.normpath(
                " ".join(proc.info.get("cmdline") or [])))
        except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
            continue
        if "brave" in name and target in joined:
            victims.append(proc)
    if not victims:
        print("nothing running - the instance is already stopped.")
        return 0
    for proc in victims:
        with contextlib.suppress(Exception):
            proc.terminate()
    alive = psutil.wait_procs(victims, timeout=8.0)[1]
    for proc in alive:
        with contextlib.suppress(Exception):
            proc.kill()
    psutil.wait_procs(alive, timeout=3.0)
    print(f"stopped {len(victims)} process(es) holding {_profile_dir()}")
    print("Start it again with: hermes-browser.cmd (it also runs at next logon).")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(add_help=True)
    ap.add_argument("--port", type=int, default=9222)
    ap.add_argument("--list", action="store_true", help="print tabs and exit")
    ap.add_argument("--keep-blank", action="store_true",
                    help="keep one about:blank tab instead of closing every tab")
    ap.add_argument("--stop", action="store_true",
                    help="stop the instance itself (kills only processes holding this profile dir)")
    args = ap.parse_args()
    if args.stop:
        return _stop_instance()
    base = f"http://127.0.0.1:{args.port}"

    version = _json(base + "/json/version")
    if not version:
        print(f"X no CDP endpoint on 127.0.0.1:{args.port} - "
              "run hermes-browser.cmd to (re)start the instance.")
        return 1
    print(f"browser: {version.get('Browser')}  port: {args.port}")

    tabs = _tabs(base)
    print(f"tabs before: {len(tabs)}")
    for t in tabs:
        print(f"  - {t.get('title') or ''} | {t.get('url') or ''}")
    if args.list:
        return 0
    if not tabs:
        print("nothing to close (already idle: no window, no taskbar entry).")
        return 0

    keep = None
    if args.keep_blank:
        keep = next((t["id"] for t in reversed(tabs)
                     if (t.get("url") or "").startswith("about:blank")), None)
        if keep is None and _http(base + "/json/new?about:blank", "PUT")[0] == 200:
            fresh = [t for t in _tabs(base)
                     if (t.get("url") or "").startswith("about:blank")]
            keep = fresh[-1]["id"] if fresh else None
            if keep:
                print(f"created keep-alive about:blank ({keep})")
        if keep is None:
            keep = tabs[-1]["id"]
            print(f"! could not create a blank tab; keeping the last one ({keep})")

    closed = sum(1 for t in tabs if t["id"] != keep and _close(base, t["id"]))
    print(f"closed {closed} tab(s)" + (" -- kept 1" if keep else ""))

    if _alive(base, 5.0):
        print(f"tabs after: {len(_tabs(base))} (instance still up, now window-less)")
        print("OK - no window, no taskbar entry; the instance keeps running.")
        return 0

    print("! the instance exited with its last tab; restarting it")
    _relaunch()
    if _alive(base, 20.0):
        print("OK - instance restarted (it will hold one blank tab again).")
        return 0
    print("X could not restart it - run hermes-browser.cmd by hand and check its output.")
    return 1


if __name__ == "__main__":
    sys.exit(main())