# -*- coding: utf-8 -*- """ Account service layer — use cases invoked by CLI commands. Each function handles: 1. Input validation 2. Logging key nodes 3. Calling the repository layer 4. Formatting JSON/stdout output Raw secret is NEVER logged or printed in plaintext. """ import json import os import shutil import subprocess import sys import tempfile import time import uuid from typing import Optional from db.accounts_repo import ( acquire_lease, cleanup_expired_leases, account_exists_with_login_id, find_account_ids, find_accounts, find_credentials, get_account_by_id, get_active_lease_by_token, get_credential_by_id, get_platform_from_db, insert_account, insert_credential, list_active_leases, list_platforms_from_db, platform_exists, release_lease, resolve_auth_strategy_for_platform, update_account_error, update_account_last_used, update_account_session_status, update_credential_last_used, upsert_platform, ) from db.auth_strategy import ( ALL_AUTH_STRATEGIES, AUTH_STRATEGY_CLIENT_CERTIFICATE, AUTH_STRATEGY_DEVICE_BOUND, AUTH_STRATEGY_PASSWORD_PLUS_2FA, AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_QR_CODE_MANUAL, AUTH_STRATEGY_SSO_REDIRECT, ) from db.credentials_repo import ( CredentialNotEncryptedError, insert_credential_encrypted, list_credentials_by_account, read_credential_plaintext, ) from db.connection import get_conn, init_db from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger from util.platforms import ( PLATFORMS, _platform_list_cn_for_help, resolve_platform_key, seed_platforms, ) from util.runtime_paths import ( get_default_profile_dir_for_web, get_skill_data_dir, _runtime_paths_debug_text, ) from service.crypto import CredentialDecryptError from service.master_key import MasterKeyMissingError, is_master_key_available from service.secret_store import ( mask_secret, make_windows_credential_target, read_secret, write_secret, ) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _map_secret_read_error(exc: BaseException) -> tuple[str, str]: """Map secret read/write exceptions to ERROR:* codes (no secret text in code).""" msg = str(exc) if "SECRET_ENV_MISSING" in msg: return "ERROR:SECRET_ENV_MISSING", msg if "WINDOWS_CREDENTIAL_UNAVAILABLE" in msg: return "ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE", msg return "ERROR:SECRET_READ_FAILED", msg def _credential_ok_line(payload: dict) -> str: """Single-line JSON for credential commands (machine integration).""" return json.dumps({"success": True, **payload}, ensure_ascii=False) def _derive_session_persistent(auth_strategy: str) -> int: """Derive default session_persistent from auth_strategy when CLI omits it.""" if auth_strategy in ( AUTH_STRATEGY_QR_CODE_MANUAL, AUTH_STRATEGY_PASSWORD_PLUS_2FA, AUTH_STRATEGY_DEVICE_BOUND, AUTH_STRATEGY_SSO_REDIRECT, ): return 1 if auth_strategy in (AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_CLIENT_CERTIFICATE): return 0 return 1 def _insert_secret_holder_account( conn, *, platform_key: str, account_label: str, environment: str, role: str, provider_code: str, url: Optional[str], now: int, ) -> int: """Create a minimal accounts row so credentials can join on environment/role.""" return insert_account( conn=conn, platform_key=platform_key, account_label=account_label, login_id=None, login_id_type="api_account", tenant_id=None, environment=environment, role=role, provider_code=provider_code, profile_dir=None, url=url, extra_json="{}", now=now, auth_strategy=resolve_auth_strategy_for_platform(conn, platform_key), ) def _err_json(code: str, message: str) -> str: """Return a JSON error line.""" return json.dumps( {"success": False, "error": {"code": code, "message": message}}, ensure_ascii=False, ) def _ok_json(data) -> str: """Return a JSON success line.""" return json.dumps( {"success": True, "data": data}, ensure_ascii=False, ) def _say(msg: str) -> None: """Print to stdout.""" print(msg) def _say_err(msg: str) -> None: """Print to stderr.""" print(msg, file=sys.stderr) def _fail_human(code: str, hint_lines: list[str]) -> None: """Print human-readable error block.""" _say(f"ERROR:{code}") for line in hint_lines: _say(line) def _remove_profile_dir(path: str) -> None: p = (path or "").strip() if not p or not os.path.isdir(p): return try: shutil.rmtree(p) except OSError as e: _say_err(f"WARNING: 未能删除用户数据目录(可手工删):{p}\n {e}") def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: """Resolve platform key or print ERROR:INVALID_PLATFORM and return None.""" raw = (input_name or "").strip() if not raw: _say("ERROR:INVALID_PLATFORM 平台名不能为空。") return None # 先尝试 platforms.py 硬编码(兼容历史) key = resolve_platform_key(raw) if key: return key # 再查 DB(支持动态注册的平台) try: init_db() conn = get_conn() try: row = conn.execute( "SELECT platform_key FROM platforms WHERE platform_key = ? AND enabled = 1", (raw,), ).fetchone() if row: return row[0] rows = conn.execute( "SELECT platform_key, aliases_json FROM platforms WHERE enabled = 1" ).fetchall() raw_lower = raw.lower() for r in rows: try: aliases = json.loads(r[1] or "[]") if raw_lower in [str(a).lower() for a in aliases]: return r[0] except Exception: continue finally: conn.close() except Exception: pass _say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。") if hint: _say(hint) else: _say("支持:" + _platform_list_cn_for_help()) return None # ============================================================================ # Platform commands # ============================================================================ def cmd_platform_list(domain: Optional[str] = None) -> None: """platform list [--domain logistics|llm|content]""" log = get_skill_logger() log.info("cli_start subcommand=platform_list domain=%s", domain) init_db() conn = get_conn() try: seed_platforms(conn) platforms = list_platforms_from_db(conn, domain=domain) finally: conn.close() _say(_ok_json({"platforms": platforms})) def cmd_platform_get(input_key: str) -> None: """platform get """ log = get_skill_logger() log.info("cli_start subcommand=platform_get key=%s", input_key) key = _resolve_or_fail(input_key) if not key: return init_db() conn = get_conn() try: plat = get_platform_from_db(conn, key) finally: conn.close() if plat: _say(_ok_json(plat)) else: _say(_err_json("ERROR:INVALID_PLATFORM", f"Platform '{key}' not found in DB.")) def cmd_platform_ensure( key: str, display_name: str, domain: str = "generic", url: str = "", auth_strategy: Optional[str] = None, aliases: Optional[str] = None, capabilities: Optional[str] = None, ) -> int: """ 幂等地注册或更新平台到 DB。 输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"} """ log = get_skill_logger() key = (key or "").strip() display_name = (display_name or key).strip() if not key: _say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数') return 1 aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False) capabilities_json = capabilities if capabilities else '{"web": true, "api_key": false, "rpa": true}' init_db() conn = get_conn() try: existed = bool( conn.execute( "SELECT 1 FROM platforms WHERE platform_key = ?", (key,) ).fetchone() ) upsert_platform( conn, key=key, display_name=display_name, domain=domain, default_url=url, aliases_json=aliases_json, capabilities_json=capabilities_json, default_auth_strategy=auth_strategy, ) finally: conn.close() action = "updated" if existed else "created" log.info("platform_ensure key=%s action=%s", key, action) _say(json.dumps({"ok": True, "platform_key": key, "action": action}, ensure_ascii=False)) return 0 # ============================================================================ # Account add-web # ============================================================================ 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, ) -> None: """account add-web — create a web/RPA account record.""" 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_fail(platform_input) if not key: return init_db() platform_spec = PLATFORMS.get(key, {}) now = int(time.time()) safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}" extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: _say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。")) return # Auto-generate or use provided profile_dir if profile_dir_override: resolved_profile_dir = os.path.abspath(profile_dir_override) else: resolved_profile_dir = get_default_profile_dir_for_web( platform_key=key, label=safe_label, login_id=login_id, domain=platform_spec.get("domain", "generic"), ) os.makedirs(resolved_profile_dir, exist_ok=True) resolved_url = (url or "").strip() or platform_spec.get("default_url", "") resolved_provider = provider_code or platform_spec.get("provider_code") or key resolved_extra = json.dumps(extra, ensure_ascii=False) conn = get_conn() try: seed_platforms(conn) if auth_strategy is None: resolved_auth = resolve_auth_strategy_for_platform(conn, key) else: if auth_strategy not in ALL_AUTH_STRATEGIES: _say(_err_json( "ERR_AUTH_STRATEGY_NOT_SUPPORTED", f"不支持的 auth_strategy:{auth_strategy}", )) return resolved_auth = auth_strategy if session_persistent is None: sp = _derive_session_persistent(resolved_auth) else: if session_persistent not in (0, 1): _say(_err_json( "ERR_INVALID_SESSION_PERSISTENT", "session_persistent 必须是 0 或 1。", )) return sp = session_persistent if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND: _say(_err_json( "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE", "device_fingerprint 仅在 auth_strategy=device_bound 时可设置。", )) return new_id = insert_account( conn=conn, platform_key=key, account_label=safe_label, login_id=login_id or None, login_id_type=login_id_type, tenant_id=tenant_id or None, environment=environment, role=role, provider_code=resolved_provider, profile_dir=resolved_profile_dir, url=resolved_url, extra_json=resolved_extra, now=now, auth_strategy=resolved_auth, session_persistent=sp, device_fingerprint=device_fingerprint, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_web_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) _say(_ok_json({ "id": new_id, "platform_key": key, "account_label": safe_label, "profile_dir": resolved_profile_dir, "url": resolved_url, "environment": environment, "role": role, "tenant_id": tenant_id or "", "provider_code": resolved_provider, "status": "active", "session_status": "unknown", "auth_strategy": resolved_auth, "session_persistent": sp, "device_fingerprint": device_fingerprint, })) # ============================================================================ # Account add-secret # ============================================================================ def cmd_account_add_secret( platform_input: str, credential_type: str, label: Optional[str], secret_storage: str, secret_stdin: bool, secret_ref: Optional[str], account_id: Optional[int], environment: Optional[str], role: Optional[str], extra_json_str: Optional[str], ) -> None: """account add-secret — create a credential record with masked secret.""" log = get_skill_logger() log.info("account_add_secret_attempt platform=%s type=%s storage=%s", platform_input, credential_type, secret_storage) key = _resolve_or_fail(platform_input) if not key: return storage_n = secret_storage.strip().lower() if storage_n not in ("none", "env", "windows_credential", "local_encrypted"): _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted")) return # Validate storage/type combinations if storage_n == "none" and credential_type not in ("browser_profile", "note"): _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", "storage=none 仅允许 credential_type=browser_profile 或 note。")) return if storage_n == "env" and not secret_ref: _say(_err_json("ERROR:SECRET_REF_MISSING", "env 存储时必须提供 --secret-ref 环境变量名。")) return init_db() platform_spec = PLATFORMS.get(key, {}) now = int(time.time()) safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}" if storage_n == "local_encrypted": if not is_master_key_available(): _say(_err_json( "ERR_MASTER_KEY_MISSING", "master key 未初始化。先跑 `account init-master-key`。", )) return if not secret_stdin: _say(_err_json( "ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN", "local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。", )) return if credential_type not in ( "password", "api_key", "api_token", "bearer_token", "refresh_token", "oauth_client", ): _say(_err_json( "ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED", f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。", )) return if secret_ref: _say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).") extra: dict = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: extra = {} resolved_env = environment or "production" resolved_role = role or "api" extra["environment"] = resolved_env extra["role"] = resolved_role resolved_extra = json.dumps(extra, ensure_ascii=False) resolved_url = (platform_spec.get("default_url") or "").strip() or None resolved_provider = platform_spec.get("provider_code") or key plaintext = sys.stdin.readline().rstrip("\r\n") if not plaintext: _say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。")) return secret_mask_out = mask_secret(plaintext) conn = get_conn() cred_id: Optional[int] = None holder_id: Optional[int] = None try: seed_platforms(conn) holder_id = account_id if holder_id is not None: acc = get_account_by_id(holder_id) if not acc: _say(_err_json( "ERROR:ACCOUNT_NOT_FOUND", f"account_id={holder_id} 不存在。", )) return if holder_id is None: holder_id = _insert_secret_holder_account( conn, platform_key=key, account_label=safe_label, environment=resolved_env, role=resolved_role, provider_code=resolved_provider, url=resolved_url, now=now, ) cred_id = insert_credential_encrypted( conn, account_id=holder_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, plaintext=plaintext, expires_at=None, extra_json=resolved_extra, now=now, ) conn.commit() except MasterKeyMissingError as e: conn.rollback() _say(_err_json("ERR_MASTER_KEY_MISSING", str(e))) return except CredentialDecryptError as e: conn.rollback() _say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e))) return except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info( "account_add_secret_encrypted_success cred_id=%s account_id=%s", cred_id, holder_id, ) _say(_ok_json({ "credential_id": cred_id, "account_id": holder_id, "platform_key": key, "credential_type": credential_type, "secret_storage": "local_encrypted", "secret_mask": secret_mask_out, })) return # For env storage: just use the env var name as ref, mask is optional if storage_n == "env": secret_ref_value = secret_ref.strip() secret_mask_value = mask_secret(f"env:{secret_ref_value}") extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: extra = {} resolved_env = environment or "production" resolved_role = role or "api" extra["environment"] = resolved_env extra["role"] = resolved_role extra_json_final = json.dumps(extra, ensure_ascii=False) resolved_url = (platform_spec.get("default_url") or "").strip() or None resolved_provider = platform_spec.get("provider_code") or key conn = get_conn() try: seed_platforms(conn) holder_id = account_id if holder_id is None: holder_id = _insert_secret_holder_account( conn, platform_key=key, account_label=safe_label, environment=resolved_env, role=resolved_role, provider_code=resolved_provider, url=resolved_url, now=now, ) cred_id = insert_credential( conn=conn, account_id=holder_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=secret_ref_value, secret_mask=secret_mask_value, expires_at=None, extra_json=extra_json_final, now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=env ref=%s", cred_id, key, secret_ref_value) _say(_ok_json({ "account_id": holder_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": secret_ref_value, "secret_mask": secret_mask_value, "warning": "env 存储方式:请确保环境变量在运行时可用。", })) return # For windows_credential: read secret from stdin if storage_n == "windows_credential": if not secret_stdin: _say(_err_json("ERROR:CLI_BAD_ARGS", "windows_credential 需要 --secret-stdin 从 stdin 读取 secret。")) return # Read secret from stdin (one line) secret_value = sys.stdin.readline().strip() if not secret_value: _say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。")) return secret_mask_value = mask_secret(secret_value) cred_label_for_target = safe_label target = make_windows_credential_target(key, credential_type, cred_label_for_target) # Write to Windows Credential Manager try: write_secret("windows_credential", target, secret_value) except (OSError, ValueError) as e: log.error("secret_write_failed storage=windows_credential error=%s", str(e)) _say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}")) return extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: extra = {} resolved_env = environment or "production" resolved_role = role or "api" extra["environment"] = resolved_env extra["role"] = resolved_role extra_json_final = json.dumps(extra, ensure_ascii=False) resolved_url = (platform_spec.get("default_url") or "").strip() or None resolved_provider = platform_spec.get("provider_code") or key conn = get_conn() try: seed_platforms(conn) holder_id = account_id if holder_id is None: holder_id = _insert_secret_holder_account( conn, platform_key=key, account_label=safe_label, environment=resolved_env, role=resolved_role, provider_code=resolved_provider, url=resolved_url, now=now, ) cred_id = insert_credential( conn=conn, account_id=holder_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=target, secret_mask=secret_mask_value, expires_at=None, extra_json=extra_json_final, now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=windows_credential", cred_id, key) # Clear secret value from memory secret_value = "" _say(_ok_json({ "account_id": holder_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": target, "secret_mask": secret_mask_value, })) return # storage = none (browser_profile / note) conn = get_conn() try: seed_platforms(conn) cred_id = insert_credential( conn=conn, account_id=account_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=None, secret_mask="", expires_at=None, extra_json=extra_json_str or "{}", now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=none", cred_id, key) _say(_ok_json({ "account_id": account_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": "", "secret_mask": "", })) # ============================================================================ # Account get (single) # ============================================================================ def cmd_account_get(account_id, include_credentials: bool = False) -> None: """Get a single account by id. Returns JSON.""" log = get_skill_logger() log.info("cli_start subcommand=account_get id=%r", account_id) init_db() acc = get_account_by_id(account_id) if not acc: log.warning("account_get_not_found id=%r", account_id) _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") _say_err(_runtime_paths_debug_text()) return if include_credentials: creds = find_credentials( platform_key=acc["platform_key"], limit=20, ) # Filter to only credentials linked to this account or any for same platform linked_creds = [c for c in creds if c["account_id"] == acc["id"]] if not linked_creds: linked_creds = [c for c in creds if c["account_id"] is None] acc["credentials"] = [ { "id": c["id"], "credential_label": c["credential_label"], "credential_type": c["credential_type"], "secret_storage": c["secret_storage"], "secret_ref": c["secret_ref"], "secret_mask": c["secret_mask"], "status": c["status"], } for c in linked_creds ] _say(json.dumps(acc, ensure_ascii=False)) def cmd_account_get_credential( account_id: int, *, reveal: bool = False, lease_token: Optional[str] = None, ) -> None: """account get-credential — metadata or plaintext when lease proves access.""" log = get_skill_logger() log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal) init_db() acc = get_account_by_id(account_id) if not acc: log.warning("account_get_credential_not_found id=%s", account_id) _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return conn = get_conn() try: creds_all = list_credentials_by_account(conn, account_id) creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"] if not creds: _say(_err_json( "ERR_CREDENTIAL_MISSING", f"账号 id={account_id} 没有活跃凭据。", )) return cred = creds[0] base_out = { "success": True, "account_id": account_id, "credential_id": cred["id"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_mask": cred["secret_mask"], } if not reveal: _say(json.dumps(base_out, ensure_ascii=False)) return tok = (lease_token or "").strip() if not tok: log.warning("account_get_credential_reveal_missing_token id=%s", account_id) _say(_err_json( "ERR_LEASE_INVALID", "reveal 必须带 --lease-token。", )) return lease = get_active_lease_by_token(conn, tok) lease_prefix = tok[:8] if lease is None: log.warning( "account_get_credential_reveal_bad_lease id=%s lease_prefix=%s", account_id, lease_prefix, ) _say(_err_json( "ERR_LEASE_INVALID", "lease token 无效或已过期", )) return if int(lease["account_id"]) != int(account_id): log.warning( "account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s", account_id, lease["account_id"], lease_prefix, ) _say(_err_json( "ERR_LEASE_INVALID", "lease token 不属于该 account", )) return try: pt = read_credential_plaintext(conn, int(cred["id"])) except MasterKeyMissingError as e: log.warning("account_get_credential_master_missing id=%s", account_id) _say(_err_json("ERR_MASTER_KEY_MISSING", str(e))) return except CredentialDecryptError as e: log.warning( "account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e))) return except CredentialNotEncryptedError: out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"} log.info( "account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(json.dumps(out, ensure_ascii=False)) return log.info( "account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False)) finally: conn.close() # Legacy alias def cmd_get(account_id) -> None: """Legacy: get — same as account get .""" cmd_account_get(account_id, include_credentials=False) # ============================================================================ # Account list # ============================================================================ def cmd_account_list( platform_input: Optional[str] = None, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, limit: int = 200, ) -> None: """account list — output JSON array of accounts matching filters.""" log = get_skill_logger() log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s", platform_input, environment, tenant_id, role) init_db() key = None if platform_input and platform_input not in ("all", "全部", ""): key = _resolve_or_fail(platform_input) if not key: return accounts = find_accounts( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, limit=limit, ) _say(json.dumps(accounts, ensure_ascii=False)) # Legacy alias def cmd_list_json(platform_input: str, limit: int = 200) -> None: """Legacy: list-json [--limit N]""" log = get_skill_logger() log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit) init_db() raw = (platform_input or "all").strip() if not raw or raw.lower() == "all" or raw == "全部": key = None else: key = _resolve_or_fail(raw) if not key: return lim = max(1, min(int(limit), 500)) accounts = find_accounts(platform_key=key, limit=lim) _say(json.dumps(accounts, ensure_ascii=False)) # ============================================================================ # Account pick-web # ============================================================================ def cmd_account_pick_web( platform_input: str, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, do_lease: bool = False, ttl_sec: int = 900, holder: Optional[str] = None, purpose: Optional[str] = None, ) -> None: """Pick a web/RPA account for use. Optionally acquire a lease.""" log = get_skill_logger() log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s", platform_input, environment, tenant_id, role, do_lease) key = _resolve_or_fail(platform_input) if not key: return init_db() conn = get_conn() try: seed_platforms(conn) finally: conn.close() candidate_ids = find_account_ids( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, ) if not candidate_ids: _say(_err_json("ERROR:NO_ACCOUNT", "没有符合条件的可用账号(status=active 且未被其他 lease 占用)。")) return picked_id = candidate_ids[0] acc = get_account_by_id(picked_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。")) return # Update last_used_at update_account_last_used(picked_id) result = { "id": acc["id"], "platform_key": acc["platform_key"], "account_label": acc["account_label"], "login_id": acc["login_id"], "profile_dir": acc["profile_dir"], "url": acc["url"], "tenant_id": acc["tenant_id"], "environment": acc["environment"], "role": acc["role"], "provider_code": acc["provider_code"], "session_status": acc["session_status"], "auth_strategy": acc["auth_strategy"], "session_persistent": acc["session_persistent"], "device_fingerprint": acc.get("device_fingerprint"), } # Lease handling lease_info = None if do_lease: lease_token = uuid.uuid4().hex[:32] resolved_holder = holder or "unknown" try: lease_info = acquire_lease( account_id=picked_id, lease_token=lease_token, holder=resolved_holder, purpose=purpose, ttl_sec=ttl_sec, ) result["lease_token"] = lease_info["lease_token"] result["lease_expires_at"] = lease_info["expires_at"] except ValueError as e: log.warning("account_pick_web_lease_conflict id=%s", picked_id) _say(_err_json("ERROR:LEASE_CONFLICT", str(e))) return log.info("account_pick_web_success id=%s token_prefix=%s", picked_id, lease_info["lease_token"][:8] if lease_info else "none") _say(json.dumps(result, ensure_ascii=False)) # Legacy alias def cmd_pick_web(platform_input: str) -> None: """Legacy: pick-web — same as account pick-web without lease.""" cmd_account_pick_web(platform_input, do_lease=False) # ============================================================================ # Credential pick / resolve # ============================================================================ def cmd_credential_pick( platform_input: str, credential_type: Optional[str] = None, environment: Optional[str] = None, role: Optional[str] = None, reveal: bool = False, ) -> None: """Pick a credential for a platform, optionally with reveal.""" log = get_skill_logger() log.info("cli_start subcommand=credential_pick platform=%s type=%s env=%s role=%s reveal=%s", platform_input, credential_type, environment, role, reveal) key = _resolve_or_fail(platform_input) if not key: return init_db() creds = find_credentials( platform_key=key, credential_type=credential_type, environment=environment, role=role, status="active", limit=1, ) if not creds: _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", "没有符合条件的可用凭据。")) return cred = creds[0] update_credential_last_used(cred["id"]) result = { "id": cred["id"], "platform_key": cred["platform_key"], "credential_label": cred["credential_label"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_ref": cred["secret_ref"], "secret_mask": cred["secret_mask"], "status": cred["status"], } if reveal: try: secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) result["secret"] = secret_value log.info( "credential_pick_reveal_success id=%s storage=%s", cred["id"], cred["secret_storage"], ) except (ValueError, OSError) as e: code, msg = _map_secret_read_error(e) log.error("credential_pick_reveal_failure id=%s error=%s", cred["id"], msg) _say(_err_json(code, msg)) return else: log.info("credential_pick_success id=%s (masked)", cred["id"]) _say(_credential_ok_line(result)) def cmd_credential_resolve(credential_id: int, reveal: bool = False) -> None: """Resolve a credential by id.""" log = get_skill_logger() log.info("cli_start subcommand=credential_resolve id=%s reveal=%s", credential_id, reveal) init_db() cred = get_credential_by_id(credential_id) if not cred: _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", f"凭据 id={credential_id} 不存在。")) return if cred["status"] != "active": _say(_err_json("ERROR:CREDENTIAL_DISABLED", f"凭据 id={credential_id} 状态为 {cred['status']},不可用。")) return result = { "id": cred["id"], "account_id": cred["account_id"], "platform_key": cred["platform_key"], "credential_label": cred["credential_label"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_ref": cred["secret_ref"], "secret_mask": cred["secret_mask"], "status": cred["status"], } if reveal: try: secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) result["secret"] = secret_value log.info("credential_resolve_reveal_success id=%s", credential_id) except (ValueError, OSError) as e: code, msg = _map_secret_read_error(e) log.error("credential_resolve_reveal_failure id=%s error=%s", credential_id, msg) _say(_err_json(code, msg)) return else: log.info("credential_resolve_success id=%s (masked)", credential_id) _say(_credential_ok_line(result)) # ============================================================================ # Lease commands # ============================================================================ def cmd_lease_release(lease_token: str) -> None: """lease release """ log = get_skill_logger() log.info("cli_start subcommand=lease_release token_prefix=%s", lease_token[:8]) ok = release_lease(lease_token) if ok: _say(_ok_json({"lease_token": lease_token, "status": "released"})) else: _say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。")) def cmd_lease_list(active_only: bool = False) -> None: """lease list [--active]""" log = get_skill_logger() log.info("cli_start subcommand=lease_list active_only=%s", active_only) leases = list_active_leases() if active_only: # Already filtered in list_active_leases pass _say(_ok_json({"leases": leases})) def cmd_lease_cleanup() -> None: """lease cleanup — mark expired leases as expired.""" log = get_skill_logger() log.info("cli_start subcommand=lease_cleanup") count = cleanup_expired_leases() _say(_ok_json({"cleaned": count})) # ============================================================================ # Account mark commands # ============================================================================ def cmd_account_mark_session(account_id: int, session_status: str) -> None: """account mark-session --session-status """ log = get_skill_logger() log.info("account_mark_session id=%s status=%s", account_id, session_status) valid_statuses = ("unknown", "likely_valid", "needs_login", "expired") if session_status not in valid_statuses: _say(_err_json("ERROR:CLI_BAD_ARGS", f"session_status 必须为 {valid_statuses} 之一。")) return acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_session_status(account_id, session_status) _say(_ok_json({"id": account_id, "session_status": session_status})) def cmd_account_mark_error(account_id: int, error_code: str, error_message: str) -> None: """account mark-error --code --message """ log = get_skill_logger() log.info("account_mark_error id=%s code=%s", account_id, error_code) acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_error(account_id, error_code, error_message) _say(_ok_json({"id": account_id, "error_code": error_code})) def cmd_account_mark_used(account_id: int) -> None: """account mark-used """ log = get_skill_logger() log.info("account_mark_used id=%s", account_id) acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_last_used(account_id) _say(_ok_json({"id": account_id, "marked": "used"})) # ============================================================================ # Legacy delete commands (kept for compatibility) # ============================================================================ def cmd_delete_by_id(account_id) -> None: """delete id — legacy""" log = get_skill_logger() log.info("delete_by_id id=%r", account_id) acc = get_account_by_id(account_id) if not acc: _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") _say_err(_runtime_paths_debug_text()) return profile_dir = acc.get("profile_dir", "") conn = get_conn() try: cur = conn.cursor() cur.execute("DELETE FROM accounts WHERE id = ?", (int(account_id),)) # Also delete linked credentials cur.execute("DELETE FROM credentials WHERE account_id = ?", (int(account_id),)) conn.commit() finally: conn.close() _remove_profile_dir(profile_dir) log.info("delete_by_id_done id=%s", account_id) _say(f"✅ 已删除账号 ID {account_id}。") def cmd_delete_by_platform(platform_input: str) -> None: """delete platform — legacy""" log = get_skill_logger() log.info("delete_by_platform platform=%r", platform_input) key = _resolve_or_fail(platform_input) if not key: return conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT id, profile_dir FROM accounts WHERE platform_key = ?", (key,)) rows = cur.fetchall() if not rows: _say("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。") return for rid, pdir in rows: _remove_profile_dir(pdir) cur.execute("DELETE FROM accounts WHERE platform_key = ?", (key,)) cur.execute( "DELETE FROM credentials WHERE platform_key = ? AND account_id IS NOT NULL", (key,), ) conn.commit() log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) _say(f"✅ 已删除 {PLATFORMS.get(key, {}).get('display_name', key)} 下共 {len(rows)} 条账号。") finally: conn.close() def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None: """Legacy: delete by platform+phone — now uses login_id matching""" log = get_skill_logger() log.info("delete_by_platform_phone platform=%r", platform_input) key = _resolve_or_fail(platform_input) if not key: return phone_norm = "".join(c for c in phone if c.isdigit()) conn = get_conn() try: cur = conn.cursor() cur.execute( "SELECT id, profile_dir FROM accounts WHERE platform_key = ? AND login_id = ?", (key, phone_norm), ) row = cur.fetchone() if not row: _say("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。") return rid, pdir = row _remove_profile_dir(pdir) cur.execute("DELETE FROM accounts WHERE id = ?", (rid,)) cur.execute("DELETE FROM credentials WHERE account_id = ?", (rid,)) conn.commit() log.info("delete_by_platform_phone_done id=%s", rid) _say(f"✅ 已删除账号 ID {rid}。") finally: conn.close() # ============================================================================ # Legacy list (human readable) # ============================================================================ def cmd_list(platform="all", limit: int = 10) -> None: """Legacy: human-readable list output.""" log = get_skill_logger() log.info("list filter=%r", platform) init_db() accounts = find_accounts( platform_key=None if (not platform or platform in ("all", "全部")) else platform, limit=limit, ) if not accounts: _say("ERROR:NO_ACCOUNTS_FOUND") _say_err(_runtime_paths_debug_text()) return log.info("list_ok rows=%s", len(accounts)) for i, acc in enumerate(accounts): _say(f"id:{acc['id']}") _say(f"label:{acc['account_label']}") _say(f"platform:{acc['platform_key']}") _say(f"login_id:{acc['login_id']}") _say(f"env:{acc['environment']} role:{acc['role']}") _say(f"status:{acc['status']} session:{acc['session_status']}") _say(f"profile_dir:{acc['profile_dir']}") _say(f"url:{acc['url']}") _say(f"created_at:{acc['created_at']}") _say(f"updated_at:{acc['updated_at']}") if acc.get("last_error_code"): _say(f"last_error:{acc['last_error_code']} {acc.get('last_error_message', '')}") if i < len(accounts) - 1: _say("_______________________________________") _say("")