fix: account open fire-and-forget; open-browser direct+sync
All checks were successful
技能自动化发布 / release (push) Successful in 8s

This commit is contained in:
2026-07-31 11:43:11 +08:00
parent f48e8aa75d
commit 0f54204a61
6 changed files with 131 additions and 30 deletions

View File

@@ -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 <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。数据管理「账号信息」行内亦提供 **打开浏览器** / **打开文件位置**(见 `assets/actions.json`)。
8. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 拉起 Profile 后立即返回,不做站点侧登录判定。数据管理「账号信息」行内 **打开浏览器** / **打开文件位置** 均为 `interaction: direct` + `sync`(见 `assets/actions.json`)。
9. **打开 Profile 目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。

View File

@@ -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" }

View File

@@ -54,7 +54,7 @@
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
| `account open <id>` | 打开持久化浏览器 profile(仅查看;**拉起后立即返回**,不等待关闭) |
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
### D. 加密备份与迁移(v2)

View File

@@ -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,
)
)

View File

@@ -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(

View File

@@ -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: