From 0f54204a6120d71c2cc214b3037ce5748503a00c Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Fri, 31 Jul 2026 11:43:11 +0800 Subject: [PATCH] fix: account open fire-and-forget; open-browser direct+sync --- SKILL.md | 4 +- assets/actions.json | 9 ++-- references/CLI.md | 2 +- scripts/service/browser_service.py | 74 ++++++++++++++++++++++------ scripts/service/open_child_runner.py | 14 +++++- tests/test_browser_open.py | 58 ++++++++++++++++++++-- 6 files changed, 131 insertions(+), 30 deletions(-) diff --git a/SKILL.md b/SKILL.md index 9644ab9..2be2a24 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.15 +version: 2.0.16 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 `)— 仅查看,不做站点侧登录判定。数据管理「账号信息」行内亦提供 **打开浏览器** / **打开文件位置**(见 `assets/actions.json`)。 +8. **浏览器打开查看**:`account open `(或兼容别名 `open `)— 拉起 Profile 后立即返回,不做站点侧登录判定。数据管理「账号信息」行内 **打开浏览器** / **打开文件位置** 均为 `interaction: direct` + `sync`(见 `assets/actions.json`)。 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 98af580..e30771a 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -34,13 +34,10 @@ { "id": "open-browser", "label": "打开浏览器", - "description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。", + "description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。一点即开,不等待关闭。", "placements": ["row"], - "executionProfile": "async", - "interaction": "confirm", - "confirmation": { - "message": "将用该账号的浏览器 Profile 打开 Chrome/Edge,仅供查看,不会自动登录或改库。" - }, + "executionProfile": "sync", + "interaction": "direct", "bind": { "tables": ["accounts"], "inputMapping": { "accountId": "$row.$pk" } diff --git a/references/CLI.md b/references/CLI.md index 7657c4c..ab45d9b 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 ` | 打开持久化浏览器 profile(仅查看;**拉起后立即返回**,不等待关闭) | | `account reveal-profile ` | 在系统文件管理器中打开该账号 `profile_dir` | ### D. 加密备份与迁移(v2) diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index c3ac6fb..30531fb 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -166,10 +166,33 @@ 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。 """ log = get_skill_logger() init_db() @@ -179,26 +202,24 @@ def cmd_open(account_id): if not target: log.warning("browser_open_error account_id=%r not_found", account_id) print("ERROR:ACCOUNT_NOT_FOUND") - return + sys.exit(1) channel = resolve_chromium_channel() if not channel: _print_browser_install_hint() - return + sys.exit(1) try: import playwright # noqa: F401 except ImportError: - print( - "ERROR:需要 playwright Python 包。" - ) - return + print("ERROR:需要 playwright Python 包。") + sys.exit(1) 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 为空。") - return + sys.exit(1) os.makedirs(profile_dir, exist_ok=True) conn = get_conn() @@ -216,28 +237,49 @@ def cmd_open(account_id): ) browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" - print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)") - print(f"地址:{url}") - print("本命令不做登录检测。") - 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: - subprocess.run( + child = _spawn_browser_child( [sys.executable, "-m", "service.open_child_runner", cfg_path], cwd=_SCRIPTS_ROOT, env=subprocess_env_with_trace(), ) - log.info("browser_open_close account_id=%s channel=%s", account_id, channel) except Exception as e: - log.error("browser_open_error account_id=%s error=%s", account_id, str(e)) - raise - finally: 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}") + sys.exit(1) + + log.info( + "browser_open_spawned account_id=%s channel=%s pid=%s", + account_id, + channel, + child.pid, + ) + print( + json.dumps( + { + "success": True, + "data": { + "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), + "url": url, + "detached": True, + "pid": child.pid, + }, + }, + ensure_ascii=False, + ) + ) diff --git a/scripts/service/open_child_runner.py b/scripts/service/open_child_runner.py index 377fada..5ed58f9 100644 --- a/scripts/service/open_child_runner.py +++ b/scripts/service/open_child_runner.py @@ -1,5 +1,9 @@ -"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。""" +"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。 + +父进程拉起后即返回;本子进程自行维护浏览器生命周期,读完配置后删除临时 JSON。 +""" import json +import os import sys from playwright.sync_api import sync_playwright @@ -12,8 +16,14 @@ from util.playwright_stealth import ( def main(): - with open(sys.argv[1], encoding="utf-8") as f: + 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( diff --git a/tests/test_browser_open.py b/tests/test_browser_open.py index 735df62..a01d71c 100644 --- a/tests/test_browser_open.py +++ b/tests/test_browser_open.py @@ -14,8 +14,7 @@ class TestPlaywrightLaunchHelpers: args, _ignore = persistent_context_launch_parts() assert "--start-maximized" in args - def test_open_child_runner_launch_keywords_documented(self): - """launch_persistent_context kwargs contract (static read of source).""" + def test_open_child_runner_cleans_config_after_read(self): import os path = os.path.join( @@ -28,10 +27,63 @@ class TestPlaywrightLaunchHelpers: 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 - assert '"channel"' in src or "'channel'" in src + + +class TestCmdOpenDetached: + def test_cmd_open_spawns_and_returns(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 + + seed_platforms(clean_db) + profile = tmp_path / "prof" + aid = insert_account( + clean_db, + platform_key="sohu", + account_label="open-test", + login_id="1", + login_id_type="phone", + tenant_id=None, + environment="production", + role="default", + provider_code="sohu", + profile_dir=str(profile), + url="https://example.com", + extra_json=None, + now=1_700_000_000, + auth_strategy="password_auto", + ) + clean_db.commit() + + monkeypatch.setattr(browser_service, "resolve_chromium_channel", lambda: "chrome") + + class FakeProc: + pid = 4242 + + spawned = {} + + def fake_spawn(cmd, *, cwd, env): + spawned["cmd"] = cmd + spawned["cwd"] = cwd + return FakeProc() + + monkeypatch.setattr(browser_service, "_spawn_browser_child", fake_spawn) + + 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"] class TestResolveChromiumChannel: