Compare commits

...

1 Commits

Author SHA1 Message Date
87877010ac fix: account open launches Chrome/Edge directly like reveal-profile
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 11:58:18 +08:00
6 changed files with 80 additions and 165 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号与凭据管理 name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" 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: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -34,7 +34,7 @@ allowed-tools:
5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
8. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 拉起 Profile 后立即返回,不做站点侧登录判定。数据管理「账号信息」行内 **打开浏览器** / **打开文件位置** 均为 `interaction: direct` + `sync`(见 `assets/actions.json`)。 8. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(`--user-data-dir`),与「打开文件位置」同模式;不做登录判定。数据管理行内按钮均为 `interaction: direct` + `sync`。
9. **打开 Profile 目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。 9. **打开 Profile 目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。

View File

@@ -34,7 +34,7 @@
{ {
"id": "open-browser", "id": "open-browser",
"label": "打开浏览器", "label": "打开浏览器",
"description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。一点即开,不等待关闭。", "description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开文件位置同为一点即开。",
"placements": ["row"], "placements": ["row"],
"executionProfile": "sync", "executionProfile": "sync",
"interaction": "direct", "interaction": "direct",

View File

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

View File

@@ -2,7 +2,7 @@
""" """
Chromium/Edge browser profile opener. 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 download Playwright Chromium.
Does NOT check login status. Does NOT check login status.
Does NOT write secret to SQLite. Does NOT write secret to SQLite.
@@ -11,20 +11,12 @@ import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
from db.connection import get_conn, init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import get_skill_logger
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS 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): def _win_find_exe(candidates):
for p in 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): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。 与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
拉起浏览器子进程后立即返回(不等待窗口关闭),便于 Skill Action sync/direct。 不写数据库、不判定是否已登录、不起 Playwright / 额外 python 子进程。
""" """
log = get_skill_logger() log = get_skill_logger()
init_db() init_db()
@@ -204,23 +174,23 @@ def cmd_open(account_id):
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
sys.exit(1) sys.exit(1)
channel = resolve_chromium_channel() browser_exe = resolve_chromium_executable()
if not channel: if not browser_exe:
_print_browser_install_hint() channel = resolve_chromium_channel()
sys.exit(1) if sys.platform == "win32" or not channel:
_print_browser_install_hint()
try: sys.exit(1)
import playwright # noqa: F401 # 非 Windows:退回 PATH 上的常见命令名
except ImportError: browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
print("ERROR:需要 playwright Python 包。")
sys.exit(1)
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
sys.exit(1) 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() conn = get_conn()
try: try:
@@ -228,41 +198,48 @@ def cmd_open(account_id):
finally: finally:
conn.close() conn.close()
# 与 profile_shortcut 一致:独立 user-data-dir 保存登录态
cmd = [browser_exe, f"--user-data-dir={profile_abs}"]
if url:
cmd.append(url)
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
profile_dir, browser_exe,
profile_abs,
url, url,
channel,
target.get("platform_key"), 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: try:
child = _spawn_browser_child( # GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
[sys.executable, "-m", "service.open_child_runner", cfg_path], popen_kwargs: dict = {
cwd=_SCRIPTS_ROOT, "stdin": subprocess.DEVNULL,
env=subprocess_env_with_trace(), "stdout": subprocess.DEVNULL,
) "stderr": subprocess.DEVNULL,
except Exception as e: "close_fds": True,
try: }
os.unlink(cfg_path) if os.path.isabs(browser_exe):
except OSError: popen_kwargs["cwd"] = os.path.dirname(browser_exe)
pass if sys.platform == "win32":
log.error("browser_open_error account_id=%s error=%s", account_id, str(e)) popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
print(f"ERROR:BROWSER_OPEN_FAILED {e}") 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) sys.exit(1)
browser_name = (
"Microsoft Edge"
if os.path.basename(browser_exe).lower() == "msedge.exe"
else "Google Chrome"
)
log.info( log.info(
"browser_open_spawned account_id=%s channel=%s pid=%s", "browser_open_ok account_id=%s browser=%s pid=%s",
account_id, account_id,
channel, browser_name,
child.pid, child.pid,
) )
print( 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_id": int(target["id"]) if target.get("id") is not None else account_id,
"account_label": target.get("account_label"), "account_label": target.get("account_label"),
"browser": browser_name, "browser": browser_name,
"channel": channel, "browser_exe": browser_exe,
"profile_dir": os.path.abspath(profile_dir), "profile_dir": profile_abs,
"url": url, "url": url,
"detached": True,
"pid": child.pid, "pid": child.pid,
}, },
}, },

View File

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

View File

@@ -2,6 +2,9 @@
""" """
Browser launch helper checks (no real browser started). Browser launch helper checks (no real browser started).
""" """
import json
from unittest import mock
import pytest import pytest
@@ -14,28 +17,11 @@ class TestPlaywrightLaunchHelpers:
args, _ignore = persistent_context_launch_parts() args, _ignore = persistent_context_launch_parts()
assert "--start-maximized" in args assert "--start-maximized" in args
def test_open_child_runner_cleans_config_after_read(self):
import os
path = os.path.join( class TestCmdOpenDirectBrowser:
os.path.dirname(__file__), """account open = same-process Chrome/Edge launch (like reveal-profile)."""
"..",
"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
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
class TestCmdOpenDetached:
def test_cmd_open_spawns_and_returns(self, clean_db, tmp_path, capsys, monkeypatch):
from db.accounts_repo import insert_account from db.accounts_repo import insert_account
from service import browser_service from service import browser_service
from util.platforms import seed_platforms from util.platforms import seed_platforms
@@ -60,30 +46,39 @@ class TestCmdOpenDetached:
) )
clean_db.commit() 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: class FakeProc:
pid = 4242 pid = 5252
spawned = {} spawned = {}
def fake_spawn(cmd, *, cwd, env): def fake_popen(cmd, **kwargs):
spawned["cmd"] = cmd spawned["cmd"] = list(cmd)
spawned["cwd"] = cwd spawned["kwargs"] = kwargs
return FakeProc() 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)) browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip() out = capsys.readouterr().out.strip()
import json
payload = json.loads(out.splitlines()[-1]) payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True assert payload["success"] is True
assert payload["data"]["detached"] is True assert payload["data"]["pid"] == 5252
assert payload["data"]["pid"] == 4242 assert payload["data"]["browser_exe"] == fake_exe
assert "-m" in spawned["cmd"] assert spawned["cmd"][0] == fake_exe
assert "service.open_child_runner" in spawned["cmd"] 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: class TestResolveChromiumChannel: