From 71bef19514c00490044a71515f7a40c791341fdd Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Fri, 19 Jun 2026 11:19:10 +0800 Subject: [PATCH] chore: auto release commit (2026-06-19 11:19:10) --- scripts/service/_svc_common.py | 255 ++++ scripts/service/account_add_commands.py | 539 +++++++ scripts/service/account_mark_commands.py | 51 + scripts/service/account_query_commands.py | 325 +++++ scripts/service/account_service.py | 1583 +-------------------- scripts/service/credential_commands.py | 121 ++ scripts/service/lease_commands.py | 34 + scripts/service/legacy_commands.py | 128 ++ scripts/service/platform_commands.py | 101 ++ 9 files changed, 1607 insertions(+), 1530 deletions(-) create mode 100644 scripts/service/_svc_common.py create mode 100644 scripts/service/account_add_commands.py create mode 100644 scripts/service/account_mark_commands.py create mode 100644 scripts/service/account_query_commands.py create mode 100644 scripts/service/credential_commands.py create mode 100644 scripts/service/lease_commands.py create mode 100644 scripts/service/legacy_commands.py create mode 100644 scripts/service/platform_commands.py diff --git a/scripts/service/_svc_common.py b/scripts/service/_svc_common.py new file mode 100644 index 0000000..464773f --- /dev/null +++ b/scripts/service/_svc_common.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +"""Shared helpers for account service command modules.""" +import json +import os +import shutil +import sys +from dataclasses import dataclass, field +from typing import Optional + +from db.accounts_repo import ( + insert_account, + resolve_auth_strategy_for_platform, + ensure_platform_from_caller_meta, +) +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.connection import get_conn, init_db +from util.logging_config import get_skill_logger +from util.platforms import ( + DEFAULT_CAPABILITIES_JSON, + PlatformResolveResult, + _platform_list_cn_for_help, + is_valid_platform_key_format, + resolve_platform_key_dynamic, + seed_platforms, +) + + +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}") + + +@dataclass +class PlatformCallerMeta: + """调用方为未注册自定义平台提供的自动注册元数据(pick-web / add-web)。""" + + display_name: Optional[str] = None + url: Optional[str] = None + domain: Optional[str] = None + auth_strategy: Optional[str] = None + aliases: list[str] = field(default_factory=list) + + def is_sufficient_for_auto_register(self) -> bool: + return bool((self.display_name or "").strip() and (self.url or "").strip()) + + +def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None: + code = result.error_code or "INVALID_PLATFORM" + if not code.startswith("ERROR:"): + code = f"ERROR:{code}" + _say(_err_json(code, result.message or "无法识别平台。")) + if hint: + _say(hint) + + +def _resolve_or_auto_register( + platform_input: str, + meta: Optional[PlatformCallerMeta] = None, + *, + hint: str = "", +) -> Optional[str]: + """解析平台;未注册时可在元数据充足时自动 ensure(幂等、仅填空字段)。""" + init_db() + conn = get_conn() + try: + result = resolve_platform_key_dynamic(platform_input, conn=conn) + if result.ok: + return result.platform_key + + if result.error_code in ("PLATFORM_DISABLED", "INVALID_PLATFORM_KEY", "INVALID_PLATFORM"): + _emit_platform_resolve_error(result, hint=hint) + return None + + if result.error_code != "PLATFORM_NOT_REGISTERED": + _emit_platform_resolve_error(result, hint=hint) + if result.error_code not in ( + "PLATFORM_NOT_REGISTERED", + "INVALID_PLATFORM_KEY", + "PLATFORM_DISABLED", + ): + _say("支持:" + _platform_list_cn_for_help()) + return None + + raw_key = (platform_input or "").strip().lower() + if not is_valid_platform_key_format(raw_key): + _emit_platform_resolve_error( + PlatformResolveResult( + error_code="INVALID_PLATFORM_KEY", + message=result.message or f"平台 key 格式非法:「{platform_input}」。", + ), + hint=hint, + ) + return None + + if not meta or not meta.is_sufficient_for_auto_register(): + _emit_platform_resolve_error( + PlatformResolveResult( + error_code="PLATFORM_NOT_REGISTERED", + message=( + f"平台 {raw_key} 尚未注册。" + "请传入 --platform-display-name 与 --platform-url," + "或先执行 account platform ensure。" + ), + ), + hint=hint, + ) + return None + + if meta.auth_strategy and meta.auth_strategy not in ALL_AUTH_STRATEGIES: + _say(_err_json( + "ERR_AUTH_STRATEGY_NOT_SUPPORTED", + f"不支持的 --platform-auth-strategy:{meta.auth_strategy}", + )) + return None + + seed_platforms(conn) + action = ensure_platform_from_caller_meta( + conn, + raw_key, + display_name=(meta.display_name or "").strip(), + domain=(meta.domain or "generic").strip() or "generic", + default_url=(meta.url or "").strip(), + aliases=meta.aliases, + default_auth_strategy=meta.auth_strategy, + capabilities_json=DEFAULT_CAPABILITIES_JSON, + ) + get_skill_logger().info( + "platform_auto_registered key=%s action=%s", raw_key, action + ) + return raw_key + finally: + conn.close() + + +def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: + """Resolve platform key or emit structured JSON error and return None.""" + init_db() + conn = get_conn() + try: + result = resolve_platform_key_dynamic(input_name, conn=conn) + finally: + conn.close() + + if result.ok: + return result.platform_key + + _emit_platform_resolve_error(result, hint=hint) + if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"): + _say("支持:" + _platform_list_cn_for_help()) + return None diff --git a/scripts/service/account_add_commands.py b/scripts/service/account_add_commands.py new file mode 100644 index 0000000..9a09a11 --- /dev/null +++ b/scripts/service/account_add_commands.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +"""Account creation CLI commands (add-web, add-secret).""" +import json +import os +import sys +import time +from typing import Optional + +from db.accounts_repo import ( + get_account_by_id, + insert_account, + insert_credential, + resolve_auth_strategy_for_platform, +) +from db.auth_strategy import ( + ALL_AUTH_STRATEGIES, + AUTH_STRATEGY_DEVICE_BOUND, +) +from db.connection import get_conn, init_db +from db.credentials_repo import insert_credential_encrypted +from service._svc_common import ( + PlatformCallerMeta, + _derive_session_persistent, + _err_json, + _insert_secret_holder_account, + _ok_json, + _resolve_or_auto_register, + _resolve_or_fail, + _say, + _say_err, +) +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, + write_secret, +) +from util.logging_config import get_skill_logger +from util.platforms import get_platform_spec, seed_platforms +from util.runtime_paths import get_default_profile_dir_for_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, + platform_meta: Optional[PlatformCallerMeta] = 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_auto_register(platform_input, platform_meta) + if not key: + return + + init_db() + now = int(time.time()) + conn = get_conn() + try: + seed_platforms(conn) + platform_spec = get_platform_spec(key, conn=conn) + finally: + conn.close() + + 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 + + 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, + })) + + +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 + + 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() + conn = get_conn() + try: + seed_platforms(conn) + platform_spec = get_platform_spec(key, conn=conn) + finally: + conn.close() + 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 + + 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 + + if storage_n == "windows_credential": + if not secret_stdin: + _say(_err_json("ERROR:CLI_BAD_ARGS", + "windows_credential 需要 --secret-stdin 从 stdin 读取 secret。")) + return + 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) + + 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) + 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 + + 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": "", + })) diff --git a/scripts/service/account_mark_commands.py b/scripts/service/account_mark_commands.py new file mode 100644 index 0000000..3d144e3 --- /dev/null +++ b/scripts/service/account_mark_commands.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""Account status mark CLI commands.""" +from db.accounts_repo import ( + get_account_by_id, + update_account_error, + update_account_last_used, + update_account_session_status, +) +from service._svc_common import _err_json, _ok_json, _say +from util.logging_config import get_skill_logger + + +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"})) diff --git a/scripts/service/account_query_commands.py b/scripts/service/account_query_commands.py new file mode 100644 index 0000000..bd62242 --- /dev/null +++ b/scripts/service/account_query_commands.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +"""Account query and pick CLI commands.""" +import json +import uuid +from typing import Optional + +from db.accounts_repo import ( + acquire_lease, + find_account_ids, + find_accounts, + find_credentials, + get_account_by_id, + get_active_lease_by_token, + update_account_last_used, +) +from db.connection import get_conn, init_db +from db.credentials_repo import ( + CredentialNotEncryptedError, + list_credentials_by_account, + read_credential_plaintext, +) +from service._svc_common import ( + PlatformCallerMeta, + _err_json, + _resolve_or_auto_register, + _resolve_or_fail, + _say, + _say_err, +) +from service.crypto import CredentialDecryptError +from service.master_key import MasterKeyMissingError +from util.logging_config import get_skill_logger +from util.platforms import seed_platforms +from util.runtime_paths import _runtime_paths_debug_text + + +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, + ) + 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() + + +def cmd_get(account_id) -> None: + """Legacy: get — same as account get .""" + cmd_account_get(account_id, include_credentials=False) + + +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)) + + +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)) + + +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, + platform_meta: Optional[PlatformCallerMeta] = 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_auto_register(platform_input, platform_meta) + 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_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_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)) + + +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) diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index 3561123..d862c47 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -9,1537 +9,60 @@ Each function handles: 4. Formatting JSON/stdout output Raw secret is NEVER logged or printed in plaintext. + +Implementation is split across service/*_commands.py modules to stay within +PyArmor trial per-file line limits. This module re-exports the public API. """ -import json -import os -import shutil -import subprocess -import sys -import tempfile -import time -import uuid -from dataclasses import dataclass, field -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, - ensure_platform_from_caller_meta, +from service._svc_common import PlatformCallerMeta +from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web +from service.account_mark_commands import ( + cmd_account_mark_error, + cmd_account_mark_session, + cmd_account_mark_used, ) -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 service.account_query_commands import ( + cmd_account_get, + cmd_account_get_credential, + cmd_account_list, + cmd_account_pick_web, + cmd_get, + cmd_list_json, + cmd_pick_web, ) -from db.credentials_repo import ( - CredentialNotEncryptedError, - insert_credential_encrypted, - list_credentials_by_account, - read_credential_plaintext, +from service.credential_commands import cmd_credential_pick, cmd_credential_resolve +from service.legacy_commands import ( + cmd_delete_by_id, + cmd_delete_by_platform, + cmd_delete_by_platform_phone, + cmd_list, ) -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 ( - DEFAULT_CAPABILITIES_JSON, - PlatformResolveResult, - _platform_list_cn_for_help, - get_platform_spec, - is_valid_platform_key_format, - resolve_platform_key_dynamic, - 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}") - - -@dataclass -class PlatformCallerMeta: - """调用方为未注册自定义平台提供的自动注册元数据(pick-web / add-web)。""" - - display_name: Optional[str] = None - url: Optional[str] = None - domain: Optional[str] = None - auth_strategy: Optional[str] = None - aliases: list[str] = field(default_factory=list) - - def is_sufficient_for_auto_register(self) -> bool: - return bool((self.display_name or "").strip() and (self.url or "").strip()) - - -def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None: - code = result.error_code or "INVALID_PLATFORM" - if not code.startswith("ERROR:"): - code = f"ERROR:{code}" - _say(_err_json(code, result.message or "无法识别平台。")) - if hint: - _say(hint) - - -def _resolve_or_auto_register( - platform_input: str, - meta: Optional[PlatformCallerMeta] = None, - *, - hint: str = "", -) -> Optional[str]: - """解析平台;未注册时可在元数据充足时自动 ensure(幂等、仅填空字段)。""" - init_db() - conn = get_conn() - try: - result = resolve_platform_key_dynamic(platform_input, conn=conn) - if result.ok: - return result.platform_key - - if result.error_code in ("PLATFORM_DISABLED", "INVALID_PLATFORM_KEY", "INVALID_PLATFORM"): - _emit_platform_resolve_error(result, hint=hint) - return None - - if result.error_code != "PLATFORM_NOT_REGISTERED": - _emit_platform_resolve_error(result, hint=hint) - if result.error_code not in ( - "PLATFORM_NOT_REGISTERED", - "INVALID_PLATFORM_KEY", - "PLATFORM_DISABLED", - ): - _say("支持:" + _platform_list_cn_for_help()) - return None - - raw_key = (platform_input or "").strip().lower() - if not is_valid_platform_key_format(raw_key): - _emit_platform_resolve_error( - PlatformResolveResult( - error_code="INVALID_PLATFORM_KEY", - message=result.message or f"平台 key 格式非法:「{platform_input}」。", - ), - hint=hint, - ) - return None - - if not meta or not meta.is_sufficient_for_auto_register(): - _emit_platform_resolve_error( - PlatformResolveResult( - error_code="PLATFORM_NOT_REGISTERED", - message=( - f"平台 {raw_key} 尚未注册。" - "请传入 --platform-display-name 与 --platform-url," - "或先执行 account platform ensure。" - ), - ), - hint=hint, - ) - return None - - if meta.auth_strategy and meta.auth_strategy not in ALL_AUTH_STRATEGIES: - _say(_err_json( - "ERR_AUTH_STRATEGY_NOT_SUPPORTED", - f"不支持的 --platform-auth-strategy:{meta.auth_strategy}", - )) - return None - - seed_platforms(conn) - action = ensure_platform_from_caller_meta( - conn, - raw_key, - display_name=(meta.display_name or "").strip(), - domain=(meta.domain or "generic").strip() or "generic", - default_url=(meta.url or "").strip(), - aliases=meta.aliases, - default_auth_strategy=meta.auth_strategy, - capabilities_json=DEFAULT_CAPABILITIES_JSON, - ) - get_skill_logger().info( - "platform_auto_registered key=%s action=%s", raw_key, action - ) - return raw_key - finally: - conn.close() - - -def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: - """Resolve platform key or emit structured JSON error and return None.""" - init_db() - conn = get_conn() - try: - result = resolve_platform_key_dynamic(input_name, conn=conn) - finally: - conn.close() - - if result.ok: - return result.platform_key - - _emit_platform_resolve_error(result, hint=hint) - if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"): - _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:PLATFORM_NOT_REGISTERED", - 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 - if not is_valid_platform_key_format(key): - _say(_err_json( - "ERROR:INVALID_PLATFORM_KEY", - f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。", - )) - return 1 - - aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False) - capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON - - 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, - platform_meta: Optional[PlatformCallerMeta] = 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_auto_register(platform_input, platform_meta) - if not key: - return - - init_db() - now = int(time.time()) - conn = get_conn() - try: - seed_platforms(conn) - platform_spec = get_platform_spec(key, conn=conn) - finally: - conn.close() - - 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() - conn = get_conn() - try: - seed_platforms(conn) - platform_spec = get_platform_spec(key, conn=conn) - finally: - conn.close() - 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, - platform_meta: Optional[PlatformCallerMeta] = 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_auto_register(platform_input, platform_meta) - 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)) - display = get_platform_spec(key, conn=conn).get("display_name", key) - _say(f"✅ 已删除 {display} 下共 {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("") +from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release +from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list + +__all__ = [ + "PlatformCallerMeta", + "cmd_account_add_secret", + "cmd_account_add_web", + "cmd_account_get", + "cmd_account_get_credential", + "cmd_account_list", + "cmd_account_mark_error", + "cmd_account_mark_session", + "cmd_account_mark_used", + "cmd_account_pick_web", + "cmd_credential_pick", + "cmd_credential_resolve", + "cmd_delete_by_id", + "cmd_delete_by_platform", + "cmd_delete_by_platform_phone", + "cmd_get", + "cmd_lease_cleanup", + "cmd_lease_list", + "cmd_lease_release", + "cmd_list", + "cmd_list_json", + "cmd_pick_web", + "cmd_platform_ensure", + "cmd_platform_get", + "cmd_platform_list", +] diff --git a/scripts/service/credential_commands.py b/scripts/service/credential_commands.py new file mode 100644 index 0000000..df834f2 --- /dev/null +++ b/scripts/service/credential_commands.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Credential pick and resolve CLI commands.""" +from typing import Optional + +from db.accounts_repo import find_credentials, get_credential_by_id, update_credential_last_used +from db.connection import init_db +from service._svc_common import ( + _credential_ok_line, + _err_json, + _map_secret_read_error, + _resolve_or_fail, + _say, +) +from service.secret_store import read_secret +from util.logging_config import get_skill_logger + + +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)) diff --git a/scripts/service/lease_commands.py b/scripts/service/lease_commands.py new file mode 100644 index 0000000..f417a7f --- /dev/null +++ b/scripts/service/lease_commands.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Lease management CLI commands.""" +from db.accounts_repo import cleanup_expired_leases, list_active_leases, release_lease +from service._svc_common import _err_json, _ok_json, _say +from util.logging_config import get_skill_logger + + +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: + 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})) diff --git a/scripts/service/legacy_commands.py b/scripts/service/legacy_commands.py new file mode 100644 index 0000000..40c1407 --- /dev/null +++ b/scripts/service/legacy_commands.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""Legacy delete and human-readable list CLI commands.""" +from db.accounts_repo import get_account_by_id, find_accounts +from db.connection import get_conn, init_db +from service._svc_common import ( + _remove_profile_dir, + _resolve_or_fail, + _say, + _say_err, +) +from util.logging_config import get_skill_logger +from util.platforms import get_platform_spec +from util.runtime_paths import _runtime_paths_debug_text + + +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),)) + 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)) + display = get_platform_spec(key, conn=conn).get("display_name", key) + _say(f"✅ 已删除 {display} 下共 {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() + + +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("") diff --git a/scripts/service/platform_commands.py b/scripts/service/platform_commands.py new file mode 100644 index 0000000..8763a1c --- /dev/null +++ b/scripts/service/platform_commands.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""Platform management CLI commands.""" +import json +from typing import Optional + +from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform +from db.connection import get_conn, init_db +from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say +from util.logging_config import get_skill_logger +from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms + + +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:PLATFORM_NOT_REGISTERED", + 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 + if not is_valid_platform_key_format(key): + _say(_err_json( + "ERROR:INVALID_PLATFORM_KEY", + f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。", + )) + return 1 + + aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False) + capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON + + 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