diff --git a/SKILL.md b/SKILL.md index 00b6c66..3374fb7 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.7 +version: 2.0.8 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -30,7 +30,7 @@ allowed-tools: 1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。 2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 3. **查看/列出账号**:`account get/list` — JSON 输出。 -4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。 +4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则按调用方参数自动 `add-web` 再返回。 5. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 6. **浏览器打开查看**:`account open `(或兼容别名 `open `)— 仅查看,不做站点侧登录判定。 7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 @@ -38,6 +38,7 @@ allowed-tools: 9. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 10. **按 lease 取明文密码**:`account get-credential --reveal --lease-token ` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 11. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 +12. **有则用、无则建(网页账号)**:`account ensure-web --platform

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 ## CLI 调用 diff --git a/references/CLI.md b/references/CLI.md index 8d2a2c9..7af19a9 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -42,7 +42,8 @@ | `account get [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) | | `account get-credential [--reveal --lease-token ]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease | | `account list [--platform

] [--environment ] [--tenant-id ] [--role ] [--limit N]` | 输出账号 JSON **数组** | -| `account pick-web --platform

[--environment ] [--tenant-id ] [--role ] [--lease] [--ttl-sec 900] [--holder ] [--purpose ]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` | +| `account pick-web --platform

[...] [--lease] [...] [--ensure]` | 挑选网页自动化候选;`--ensure` 等同 `ensure-web`(零账号时自动登记) | +| `account ensure-web --platform

[--login-id ] [--label

[--label ...] [--lease --holder ]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。 ## 推荐调用方式 @@ -26,6 +28,7 @@ | 取单条账号 JSON | `account get ` | **单行 JSON 对象**(无固定 `success` 字段) | | 批量账号 JSON | `account list ...` | **单行 JSON 数组** | | 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** | +| 有则用无则建 | `account ensure-web ...` / `pick-web --ensure` | **同 pick-web**;本次新建时多 `account_ensured: true` | | 取加密凭据明文 | `account get-credential --reveal --lease-token ` | **单行 JSON**,含 `plaintext`(若可解密) | | 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) | | 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) | diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 7b1028b..13fa23b 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -4,7 +4,7 @@ CLI entry point: argv dispatch for account-manager v2. Subcommand tree: platform list/get/ensure - account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open + account add-web|ensure-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open init-master-key|export-credentials|import-credentials credential pick|resolve lease release|list|cleanup @@ -23,6 +23,7 @@ from service.account_crypto_commands import ( from service.account_service import ( cmd_account_add_secret, cmd_account_add_web, + cmd_account_ensure_web, cmd_account_get, cmd_account_get_credential, cmd_account_list, @@ -82,6 +83,12 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager account pick-web --platform

[--environment ] [--tenant-id ] [--role ] [--lease] [--ttl-sec 900] [--holder ] [--purpose ] + [--ensure] # 同 ensure-web:无账号时自动登记再 pick + account ensure-web --platform

[--login-id ] [--label

] + # 有账号则 pick;零账号则按参数 add-web 后再 pick + # 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT account mark-session --session-status account mark-error --code --message account mark-used @@ -246,12 +253,14 @@ def main() -> None: # ---- account subcommand ---- elif cmd == "account": if len(av) < 3: - print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。") + print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / ensure-web / get / …)。") print(_USAGE) sys.exit(1) sub = av[2] if sub == "add-web": _cmd_add_web(av) + elif sub == "ensure-web": + _cmd_ensure_web(av) elif sub == "add-secret": _cmd_add_secret(av) elif sub == "get-credential": @@ -543,11 +552,15 @@ def _cmd_pick_web_new(argv: list[str]) -> None: ttl = _get_int_opt(argv, "--ttl-sec", 900) holder = _get_opt(argv, "--holder") purpose = _get_opt(argv, "--purpose") + do_ensure = _has_flag(argv, "--ensure") if not plat: print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform 。") return platform_meta = _parse_platform_caller_meta(argv) + if do_ensure: + _cmd_ensure_web(argv) + return cmd_account_pick_web( platform_input=plat, environment=env, @@ -561,6 +574,61 @@ def _cmd_pick_web_new(argv: list[str]) -> None: ) +def _cmd_ensure_web(argv: list[str]) -> None: + plat = _get_opt(argv, "--platform", "") + login_id = _get_opt(argv, "--login-id") + login_id_type = _get_opt(argv, "--login-id-type", "unknown") + label = _get_opt(argv, "--label") + tenant_id = _get_opt(argv, "--tenant-id") + environment = _get_opt(argv, "--environment") + role = _get_opt(argv, "--role") + provider_code = _get_opt(argv, "--provider-code") + url = _get_opt(argv, "--url") + extra_json_str = _get_opt(argv, "--extra-json") + profile_dir_override = _get_opt(argv, "--profile-dir") + auth_strategy = _get_opt(argv, "--auth-strategy") + session_persistent_raw = _get_opt(argv, "--session-persistent") + device_fingerprint = _get_opt(argv, "--device-fingerprint") + do_lease = _has_flag(argv, "--lease") + ttl = _get_int_opt(argv, "--ttl-sec", 900) + holder = _get_opt(argv, "--holder") + purpose = _get_opt(argv, "--purpose") + + session_persistent: Optional[int] = None + if session_persistent_raw is not None: + try: + session_persistent = int(session_persistent_raw) + except ValueError: + print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数(0 或 1)。") + return + + if not plat: + print("ERROR:CLI_BAD_ARGS account ensure-web 需要 --platform 。") + return + platform_meta = _parse_platform_caller_meta(argv) + cmd_account_ensure_web( + platform_input=plat, + login_id=login_id, + login_id_type=login_id_type or "unknown", + label=label, + tenant_id=tenant_id, + environment=environment, + role=role, + provider_code=provider_code, + url=url, + extra_json_str=extra_json_str, + profile_dir_override=profile_dir_override, + auth_strategy=auth_strategy, + session_persistent=session_persistent, + device_fingerprint=device_fingerprint, + do_lease=do_lease, + ttl_sec=ttl, + holder=holder, + purpose=purpose, + platform_meta=platform_meta, + ) + + def _cmd_cred_pick(argv: list[str]) -> None: plat = _get_opt(argv, "--platform", "") ctype = _get_opt(argv, "--type") diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 0c7d72a..6becbcc 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -284,6 +284,36 @@ def find_accounts( conn.close() +def count_active_accounts( + platform_key: str, + environment: Optional[str] = None, + tenant_id: Optional[str] = None, + role: Optional[str] = None, +) -> int: + """Count active accounts matching filters (ignores lease occupancy).""" + conn = get_conn() + try: + conn.row_factory = None + cur = conn.cursor() + conditions = ["status = 'active'", "platform_key = ?"] + params: list = [platform_key] + if environment: + conditions.append("environment = ?") + params.append(environment) + if tenant_id: + conditions.append("tenant_id = ?") + params.append(tenant_id) + if role: + conditions.append("role = ?") + params.append(role) + where = " AND ".join(conditions) + cur.execute(f"SELECT COUNT(*) FROM accounts WHERE {where}", params) + row = cur.fetchone() + return int(row[0] or 0) if row else 0 + finally: + conn.close() + + def find_account_ids( platform_key: str, environment: Optional[str] = None, diff --git a/scripts/service/account_add_commands.py b/scripts/service/account_add_commands.py index 1e818f7..564c7ab 100644 --- a/scripts/service/account_add_commands.py +++ b/scripts/service/account_add_commands.py @@ -42,7 +42,7 @@ from util.profile_shortcut import create_profile_browser_shortcut from util.runtime_paths import get_default_profile_dir_for_web -def cmd_account_add_web( +def create_web_account_record( platform_input: str, login_id: Optional[str], login_id_type: str, @@ -59,15 +59,16 @@ def cmd_account_add_web( session_persistent: Optional[int] = None, device_fingerprint: Optional[str] = None, platform_meta: Optional[PlatformCallerMeta] = None, -) -> None: - """account add-web — create a web/RPA account record.""" + platform_key: Optional[str] = None, +) -> Optional[dict]: + """Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout).""" log = get_skill_logger() log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s", platform_input, login_id_type, environment, role) - key = _resolve_or_auto_register(platform_input, platform_meta) + key = platform_key or _resolve_or_auto_register(platform_input, platform_meta) if not key: - return + return None init_db() now = int(time.time()) @@ -85,7 +86,7 @@ def cmd_account_add_web( extra = json.loads(extra_json_str) except json.JSONDecodeError: _say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。")) - return + return None if profile_dir_override: resolved_profile_dir = os.path.abspath(profile_dir_override) @@ -113,7 +114,7 @@ def cmd_account_add_web( "ERR_AUTH_STRATEGY_NOT_SUPPORTED", f"不支持的 auth_strategy:{auth_strategy}", )) - return + return None resolved_auth = auth_strategy if session_persistent is None: @@ -124,7 +125,7 @@ def cmd_account_add_web( "ERR_INVALID_SESSION_PERSISTENT", "session_persistent 必须是 0 或 1。", )) - return + return None sp = session_persistent if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND: @@ -132,7 +133,7 @@ def cmd_account_add_web( "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE", "device_fingerprint 仅在 auth_strategy=device_bound 时可设置。", )) - return + return None new_id = insert_account( conn=conn, @@ -157,7 +158,7 @@ def cmd_account_add_web( conn.rollback() log.exception("account_add_web_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) - return + return None finally: conn.close() @@ -182,7 +183,47 @@ def cmd_account_add_web( } if shortcut_path: ok_payload["profile_shortcut"] = shortcut_path - _say(_ok_json(ok_payload)) + return ok_payload + + +def cmd_account_add_web( + platform_input: str, + login_id: Optional[str], + login_id_type: str, + label: Optional[str], + tenant_id: Optional[str], + environment: str, + role: str, + provider_code: Optional[str], + url: Optional[str], + extra_json_str: Optional[str], + profile_dir_override: Optional[str], + *, + auth_strategy: Optional[str] = None, + session_persistent: Optional[int] = None, + device_fingerprint: Optional[str] = None, + platform_meta: Optional[PlatformCallerMeta] = None, +) -> None: + """account add-web — create a web/RPA account record.""" + ok_payload = create_web_account_record( + platform_input, + login_id, + login_id_type, + label, + tenant_id, + environment, + role, + provider_code, + url, + extra_json_str, + profile_dir_override, + auth_strategy=auth_strategy, + session_persistent=session_persistent, + device_fingerprint=device_fingerprint, + platform_meta=platform_meta, + ) + if ok_payload: + _say(_ok_json(ok_payload)) def cmd_account_add_secret( diff --git a/scripts/service/account_ensure_commands.py b/scripts/service/account_ensure_commands.py new file mode 100644 index 0000000..2e7248e --- /dev/null +++ b/scripts/service/account_ensure_commands.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""account ensure-web — pick if present, otherwise add-web then pick.""" +from typing import Optional + +from db.accounts_repo import count_active_accounts +from db.connection import get_conn, init_db +from service._svc_common import PlatformCallerMeta, _err_json, _resolve_or_auto_register, _say +from service.account_add_commands import create_web_account_record +from service.account_query_commands import cmd_account_pick_web +from util.logging_config import get_skill_logger +from util.platforms import seed_platforms + + +def cmd_account_ensure_web( + platform_input: str, + *, + login_id: Optional[str] = None, + login_id_type: str = "unknown", + label: Optional[str] = None, + tenant_id: Optional[str] = None, + environment: Optional[str] = None, + role: Optional[str] = None, + provider_code: Optional[str] = None, + url: Optional[str] = None, + extra_json_str: Optional[str] = None, + profile_dir_override: Optional[str] = None, + auth_strategy: Optional[str] = None, + session_persistent: Optional[int] = None, + device_fingerprint: Optional[str] = None, + do_lease: bool = False, + ttl_sec: int = 900, + holder: Optional[str] = None, + purpose: Optional[str] = None, + platform_meta: Optional[PlatformCallerMeta] = None, +) -> None: + """Pick a web account; if none exist for filters, create one then pick. + + Does NOT create a new account when matching accounts exist but are all leased. + Success stdout matches ``account pick-web`` (plus ``account_ensured`` when created). + """ + log = get_skill_logger() + log.info( + "account_ensure_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s", + platform_input, + environment, + tenant_id, + role, + do_lease, + ) + + key = _resolve_or_auto_register(platform_input, platform_meta) + if not key: + return + + init_db() + conn = get_conn() + try: + seed_platforms(conn) + finally: + conn.close() + + existing_count = count_active_accounts( + platform_key=key, + environment=environment, + tenant_id=tenant_id, + role=role, + ) + created = False + if existing_count == 0: + create_env = (environment or "").strip() or "production" + create_role = (role or "").strip() or "default" + create_label = (label or "").strip() or None + if not create_label: + create_label = f"{key} 默认" + + created_payload = create_web_account_record( + platform_input, + login_id, + login_id_type, + create_label, + tenant_id, + create_env, + create_role, + provider_code, + url, + extra_json_str, + profile_dir_override, + auth_strategy=auth_strategy, + session_persistent=session_persistent, + device_fingerprint=device_fingerprint, + platform_meta=platform_meta, + platform_key=key, + ) + if not created_payload: + return + created = True + log.info( + "account_ensure_web_created id=%s platform=%s profile_dir=%s", + created_payload.get("id"), + key, + created_payload.get("profile_dir"), + ) + else: + log.info( + "account_ensure_web_reuse platform=%s existing_active=%s", + key, + existing_count, + ) + + # Capture pick output so we can annotate / remap lease-busy errors. + import io + import json + import sys + + buf = io.StringIO() + old_out = sys.stdout + sys.stdout = buf + try: + cmd_account_pick_web( + platform_input, + environment=environment, + tenant_id=tenant_id, + role=role, + do_lease=do_lease, + ttl_sec=ttl_sec, + holder=holder, + purpose=purpose, + platform_meta=platform_meta, + ) + finally: + sys.stdout = old_out + + raw = buf.getvalue().strip() + if not raw: + _say(_err_json("ERROR:NO_ACCOUNT", "ensure 后未能挑选到账号。")) + return + + try: + payload = json.loads(raw) + except json.JSONDecodeError: + _say(raw) + return + + if isinstance(payload, dict) and payload.get("success") is False: + err = (payload.get("error") or {}) if isinstance(payload.get("error"), dict) else {} + code = str(err.get("code") or "") + if code == "ERROR:NO_ACCOUNT" and existing_count > 0 and not created: + _say(_err_json( + "ERROR:LEASE_CONFLICT", + "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。", + )) + return + _say(raw) + return + + if isinstance(payload, dict) and created: + payload["account_ensured"] = True + _say(json.dumps(payload, ensure_ascii=False)) + return + + _say(raw) diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index d862c47..c38a6b8 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -15,6 +15,7 @@ PyArmor trial per-file line limits. This module re-exports the public API. """ from service._svc_common import PlatformCallerMeta from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web +from service.account_ensure_commands import cmd_account_ensure_web from service.account_mark_commands import ( cmd_account_mark_error, cmd_account_mark_session, @@ -43,6 +44,7 @@ __all__ = [ "PlatformCallerMeta", "cmd_account_add_secret", "cmd_account_add_web", + "cmd_account_ensure_web", "cmd_account_get", "cmd_account_get_credential", "cmd_account_list", diff --git a/tests/test_ensure_web.py b/tests/test_ensure_web.py new file mode 100644 index 0000000..7a905eb --- /dev/null +++ b/tests/test_ensure_web.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +"""Tests for account ensure-web (pick-or-create).""" +from __future__ import annotations + +import io +import json +import sys + +import pytest + + +def _capture(fn, *args, **kwargs): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(*args, **kwargs) + finally: + sys.stdout = old + return buf.getvalue() + + +class TestEnsureWeb: + def test_ensure_creates_when_empty(self, clean_db): + from service.account_service import cmd_account_ensure_web + + raw = _capture( + cmd_account_ensure_web, + "maersk", + label="马士基默认", + ) + result = json.loads(raw) + assert result.get("success") is not False + assert result["platform_key"] == "maersk" + assert result.get("profile_dir") + assert result.get("account_ensured") is True + assert result.get("id") + + def test_ensure_reuses_existing_no_duplicate(self, clean_db): + from service.account_service import cmd_account_add_web, cmd_account_ensure_web + from db.accounts_repo import find_accounts + + _capture( + cmd_account_add_web, + "maersk", + "a@test.com", + "email", + "已有", + None, + "production", + "default", + None, + None, + None, + None, + ) + before = find_accounts(platform_key="maersk", status="active", limit=50) + assert len(before) == 1 + + raw = _capture(cmd_account_ensure_web, "maersk") + result = json.loads(raw) + assert result.get("account_ensured") is not True + assert result["id"] == before[0]["id"] + + after = find_accounts(platform_key="maersk", status="active", limit=50) + assert len(after) == 1 + + def test_ensure_lease_busy_does_not_create(self, clean_db): + from db.accounts_repo import acquire_lease, find_account_ids, find_accounts + from service.account_service import cmd_account_add_web, cmd_account_ensure_web + + _capture( + cmd_account_add_web, + "cma_cgm", + "b@cma.com", + "email", + "CMA", + None, + "production", + "default", + None, + None, + None, + None, + ) + ids = find_account_ids("cma_cgm") + assert len(ids) == 1 + acquire_lease( + account_id=ids[0], + lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + holder="other", + purpose="busy", + ttl_sec=900, + ) + assert find_account_ids("cma_cgm") == [] + + raw = _capture(cmd_account_ensure_web, "cma_cgm", do_lease=True, holder="me") + err = json.loads(raw) + assert err["success"] is False + assert err["error"]["code"] == "ERROR:LEASE_CONFLICT" + + still = find_accounts(platform_key="cma_cgm", status="active", limit=50) + assert len(still) == 1 + + def test_pick_web_ensure_flag_cli(self, clean_db, fake_env): + """CLI account pick-web --ensure creates when empty.""" + import os + import subprocess + + scripts = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" + ) + env = { + **os.environ, + "PYTHONPATH": scripts, + "JIANGCHANG_DATA_ROOT": fake_env["data_root"], + "JIANGCHANG_USER_ID": fake_env["user_id"], + } + r = subprocess.run( + [ + sys.executable, + "-m", + "cli.app", + "account", + "pick-web", + "--platform", + "maersk", + "--ensure", + "--label", + "cli-default", + ], + capture_output=True, + text=True, + env=env, + cwd=scripts, + ) + assert r.returncode == 0, r.stderr + r.stdout + # last non-empty JSON line + lines = [ln for ln in r.stdout.splitlines() if ln.strip()] + result = json.loads(lines[-1]) + assert result.get("platform_key") == "maersk" + assert result.get("account_ensured") is True + assert result.get("profile_dir") diff --git a/tests/test_usage.py b/tests/test_usage.py index 4503ebe..d3a91d3 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -27,7 +27,9 @@ def test_usage_includes_all_account_subcommands(): "export-credentials", "import-credentials", "add-web", + "ensure-web", "get-credential", "pick-web", ): assert cmd in usage, f"USAGE 缺少 {cmd}" + assert "--ensure" in usage