From 87877010ac3f6864a39857c589946fba720803f0 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Fri, 31 Jul 2026 11:58:18 +0800 Subject: [PATCH] fix: account open launches Chrome/Edge directly like reveal-profile --- SKILL.md | 4 +- assets/actions.json | 2 +- references/CLI.md | 2 +- scripts/service/browser_service.py | 122 +++++++++++---------------- scripts/service/open_child_runner.py | 56 ------------ tests/test_browser_open.py | 59 ++++++------- 6 files changed, 80 insertions(+), 165 deletions(-) delete mode 100644 scripts/service/open_child_runner.py diff --git a/SKILL.md b/SKILL.md index 2be2a24..86a3f58 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号与凭据管理 description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" -version: 2.0.16 +version: 2.0.17 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -34,7 +34,7 @@ allowed-tools: 5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 -8. **浏览器打开查看**:`account open `(或兼容别名 `open `)— 拉起 Profile 后立即返回,不做站点侧登录判定。数据管理「账号信息」行内 **打开浏览器** / **打开文件位置** 均为 `interaction: direct` + `sync`(见 `assets/actions.json`)。 +8. **浏览器打开查看**:`account open `(或兼容别名 `open `)— **本进程**直接拉起系统 Chrome/Edge(`--user-data-dir`),与「打开文件位置」同模式;不做登录判定。数据管理行内按钮均为 `interaction: direct` + `sync`。 9. **打开 Profile 目录**:`account reveal-profile ` — 在系统文件管理器中打开该账号 `profile_dir`。 10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 diff --git a/assets/actions.json b/assets/actions.json index e30771a..b9dd030 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -34,7 +34,7 @@ { "id": "open-browser", "label": "打开浏览器", - "description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。一点即开,不等待关闭。", + "description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开文件位置同为一点即开。", "placements": ["row"], "executionProfile": "sync", "interaction": "direct", diff --git a/references/CLI.md b/references/CLI.md index ab45d9b..8f679e7 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -54,7 +54,7 @@ | `account mark-session --session-status ` | 标记会话状态 | | `account mark-error --code --message ` | 标记错误 | | `account mark-used ` | 标记已使用 | -| `account open ` | 打开持久化浏览器 profile(仅查看;**拉起后立即返回**,不等待关闭) | +| `account open ` | 本进程直接打开系统 Chrome/Edge(`--user-data-dir=profile`;无额外 python 子进程) | | `account reveal-profile ` | 在系统文件管理器中打开该账号 `profile_dir` | ### D. 加密备份与迁移(v2) diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index 30531fb..09868d1 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -2,7 +2,7 @@ """ Chromium/Edge browser profile opener. -Uses system Chrome/Edge channel, Playwright launch_persistent_context. +Uses system Chrome/Edge executable with --user-data-dir (same profile as RPA shortcuts). Does NOT download Playwright Chromium. Does NOT check login status. Does NOT write secret to SQLite. @@ -11,20 +11,12 @@ import json import os import subprocess import sys -import tempfile from db.connection import get_conn, init_db from db.accounts_repo import get_account_by_id, get_platform_from_db -from util.logging_config import ( - get_skill_logger, - subprocess_env_with_trace, -) +from util.logging_config import get_skill_logger from util.platforms import PLATFORMS -# 子进程入口脚本与本模块同目录(独立解释器执行,非 import) -_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) -_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR) - def _win_find_exe(candidates): for p in candidates: @@ -166,33 +158,11 @@ def cmd_reveal_profile(account_id): ) -def _spawn_browser_child(cmd: list[str], *, cwd: str, env: dict) -> subprocess.Popen: - """Detach Playwright child so `account open` can return immediately.""" - popen_kwargs: dict = { - "cwd": cwd, - "env": env, - "stdin": subprocess.DEVNULL, - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - "close_fds": True, - } - if sys.platform == "win32": - # Keep browser window; detach from parent console / Skill Action wait. - flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) - if create_no_window: - flags |= create_no_window - popen_kwargs["creationflags"] = flags - else: - popen_kwargs["start_new_session"] = True - return subprocess.Popen(cmd, **popen_kwargs) - - def cmd_open(account_id): """ 打开该账号的持久化浏览器,仅用于肉眼核对。 - 不写数据库、不判定是否已登录。 - 拉起浏览器子进程后立即返回(不等待窗口关闭),便于 Skill Action sync/direct。 + 与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。 + 不写数据库、不判定是否已登录、不起 Playwright / 额外 python 子进程。 """ log = get_skill_logger() init_db() @@ -204,23 +174,23 @@ def cmd_open(account_id): print("ERROR:ACCOUNT_NOT_FOUND") sys.exit(1) - channel = resolve_chromium_channel() - if not channel: - _print_browser_install_hint() - sys.exit(1) - - try: - import playwright # noqa: F401 - except ImportError: - print("ERROR:需要 playwright Python 包。") - sys.exit(1) + browser_exe = resolve_chromium_executable() + if not browser_exe: + channel = resolve_chromium_channel() + if sys.platform == "win32" or not channel: + _print_browser_install_hint() + sys.exit(1) + # 非 Windows:退回 PATH 上的常见命令名 + browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge" profile_dir = (target.get("profile_dir") or "").strip() if not profile_dir: log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") sys.exit(1) - os.makedirs(profile_dir, exist_ok=True) + + profile_abs = os.path.abspath(profile_dir) + os.makedirs(profile_abs, exist_ok=True) conn = get_conn() try: @@ -228,41 +198,48 @@ def cmd_open(account_id): finally: conn.close() + # 与 profile_shortcut 一致:独立 user-data-dir 保存登录态 + cmd = [browser_exe, f"--user-data-dir={profile_abs}"] + if url: + cmd.append(url) + log.info( - "browser_open_context profile_dir=%s url=%s channel=%s platform=%s", - profile_dir, + "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s", + browser_exe, + profile_abs, url, - channel, target.get("platform_key"), ) - browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" - cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False, encoding="utf-8" - ) as jf: - json.dump(cfg, jf, ensure_ascii=False) - cfg_path = jf.name - try: - child = _spawn_browser_child( - [sys.executable, "-m", "service.open_child_runner", cfg_path], - cwd=_SCRIPTS_ROOT, - env=subprocess_env_with_trace(), - ) - except Exception as e: - try: - os.unlink(cfg_path) - except OSError: - pass - log.error("browser_open_error account_id=%s error=%s", account_id, str(e)) - print(f"ERROR:BROWSER_OPEN_FAILED {e}") + # GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回) + popen_kwargs: dict = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + } + if os.path.isabs(browser_exe): + popen_kwargs["cwd"] = os.path.dirname(browser_exe) + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + child = subprocess.Popen(cmd, **popen_kwargs) + except OSError as exc: + log.error("browser_open_error account_id=%s error=%s", account_id, str(exc)) + print(f"ERROR:BROWSER_OPEN_FAILED {exc}") sys.exit(1) + browser_name = ( + "Microsoft Edge" + if os.path.basename(browser_exe).lower() == "msedge.exe" + else "Google Chrome" + ) log.info( - "browser_open_spawned account_id=%s channel=%s pid=%s", + "browser_open_ok account_id=%s browser=%s pid=%s", account_id, - channel, + browser_name, child.pid, ) print( @@ -273,10 +250,9 @@ def cmd_open(account_id): "account_id": int(target["id"]) if target.get("id") is not None else account_id, "account_label": target.get("account_label"), "browser": browser_name, - "channel": channel, - "profile_dir": os.path.abspath(profile_dir), + "browser_exe": browser_exe, + "profile_dir": profile_abs, "url": url, - "detached": True, "pid": child.pid, }, }, diff --git a/scripts/service/open_child_runner.py b/scripts/service/open_child_runner.py deleted file mode 100644 index 5ed58f9..0000000 --- a/scripts/service/open_child_runner.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。 - -父进程拉起后即返回;本子进程自行维护浏览器生命周期,读完配置后删除临时 JSON。 -""" -import json -import os -import sys - -from playwright.sync_api import sync_playwright - -from util.playwright_stealth import ( - STEALTH_INIT_SCRIPT, - persistent_context_launch_parts, - stealth_enabled, -) - - -def main(): - cfg_path = sys.argv[1] - with open(cfg_path, encoding="utf-8") as f: - c = json.load(f) - try: - os.unlink(cfg_path) - except OSError: - pass - - with sync_playwright() as p: - args, ignore_automation = persistent_context_launch_parts() - lc_kw = dict( - user_data_dir=c["profile_dir"], - headless=False, - channel=c["channel"], - no_viewport=True, - args=args, - ) - if ignore_automation is not None: - lc_kw["ignore_default_args"] = ignore_automation - ctx = p.chromium.launch_persistent_context(**lc_kw) - if stealth_enabled(): - ctx.add_init_script(STEALTH_INIT_SCRIPT) - try: - page = ctx.pages[0] if ctx.pages else ctx.new_page() - page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) - ctx.wait_for_event("close", timeout=86400000) - except Exception as e: - print(e, file=sys.stderr) - raise - finally: - try: - ctx.close() - except Exception: - pass - - -if __name__ == "__main__": - main() diff --git a/tests/test_browser_open.py b/tests/test_browser_open.py index a01d71c..a5fd785 100644 --- a/tests/test_browser_open.py +++ b/tests/test_browser_open.py @@ -2,6 +2,9 @@ """ Browser launch helper checks (no real browser started). """ +import json +from unittest import mock + import pytest @@ -14,28 +17,11 @@ class TestPlaywrightLaunchHelpers: args, _ignore = persistent_context_launch_parts() assert "--start-maximized" in args - def test_open_child_runner_cleans_config_after_read(self): - import os - path = os.path.join( - os.path.dirname(__file__), - "..", - "scripts", - "service", - "open_child_runner.py", - ) - path = os.path.abspath(path) - with open(path, encoding="utf-8") as f: - src = f.read() - assert "os.unlink(cfg_path)" in src - assert "wait_for_event" in src - assert "launch_persistent_context" in src - assert "headless=False" in src - assert "no_viewport=True" in src +class TestCmdOpenDirectBrowser: + """account open = same-process Chrome/Edge launch (like reveal-profile).""" - -class TestCmdOpenDetached: - def test_cmd_open_spawns_and_returns(self, clean_db, tmp_path, capsys, monkeypatch): + def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch): from db.accounts_repo import insert_account from service import browser_service from util.platforms import seed_platforms @@ -60,30 +46,39 @@ class TestCmdOpenDetached: ) clean_db.commit() - monkeypatch.setattr(browser_service, "resolve_chromium_channel", lambda: "chrome") + fake_exe = str(tmp_path / "chrome.exe") + monkeypatch.setattr( + browser_service, + "resolve_chromium_executable", + lambda: fake_exe, + ) class FakeProc: - pid = 4242 + pid = 5252 spawned = {} - def fake_spawn(cmd, *, cwd, env): - spawned["cmd"] = cmd - spawned["cwd"] = cwd + def fake_popen(cmd, **kwargs): + spawned["cmd"] = list(cmd) + spawned["kwargs"] = kwargs return FakeProc() - monkeypatch.setattr(browser_service, "_spawn_browser_child", fake_spawn) + monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen) browser_service.cmd_open(str(aid)) out = capsys.readouterr().out.strip() - import json - payload = json.loads(out.splitlines()[-1]) + assert payload["success"] is True - assert payload["data"]["detached"] is True - assert payload["data"]["pid"] == 4242 - assert "-m" in spawned["cmd"] - assert "service.open_child_runner" in spawned["cmd"] + assert payload["data"]["pid"] == 5252 + assert payload["data"]["browser_exe"] == fake_exe + assert spawned["cmd"][0] == fake_exe + assert spawned["cmd"][1].startswith("--user-data-dir=") + assert spawned["cmd"][2] == "https://example.com" + # must NOT spawn python / open_child_runner + joined = " ".join(spawned["cmd"]) + assert "open_child_runner" not in joined + assert "python" not in joined.lower() class TestResolveChromiumChannel: