Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-05-05 18:51:00 +08:00
parent 6b6d2969c3
commit 91057fa3b7
29 changed files with 4582 additions and 900 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,28 @@
"""Chromium/Edge 检测与 Playwright仅打开浏览器不写库、不做登录态 DOM 检测)。"""
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
from db.connection import init_db
from db.accounts_repo import get_account_by_id
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORM_URLS
from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import若以「脚本路径」启动子进程
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
@@ -68,11 +75,17 @@ def _print_browser_install_hint():
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。"""
get_skill_logger().info("open account_id=%r", account_id)
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
get_skill_logger().warning("open_not_found account_id=%r", account_id)
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
@@ -85,23 +98,33 @@ def cmd_open(account_id):
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 playwright install chromium"
"ERROR:需要 playwright Python 包"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
# Use account url, fall back to platform default
platform_spec = PLATFORMS.get(target["platform_key"], {})
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
channel,
target.get("platform_key"),
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
@@ -115,6 +138,10 @@ def cmd_open(account_id):
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
except Exception as e:
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
raise
finally:
try:
os.unlink(cfg_path)

View File

@@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""
Secret storage abstraction layer.
Supported storage backends:
- none : No secret stored (e.g., browser profile login state)
- env : Secret is read from environment variable at runtime
- windows_credential : Secret stored in Windows Credential Manager (ctypes)
Raw secret values are NEVER logged or written to SQLite.
"""
import os
import re
import sys
import uuid
from util.logging_config import get_skill_logger
def mask_secret(secret: str) -> str:
"""
Return a masked version of the secret for display/storage.
Never returns the full original secret or a reversible prefix.
Examples:
- Long API key -> \"****abcd\" (last 4 chars only, after separator)
- Short / empty -> \"****\"
"""
if secret is None:
return "****"
s = str(secret).strip()
if not s:
return "****"
if len(s) <= 4:
return "****"
return "****" + s[-4:]
def make_windows_credential_target(
platform_key: str,
credential_type: str,
credential_id_or_label: str,
) -> str:
"""
Generate a Windows Credential Manager target name for a credential.
Format: openclaw/account-manager/{user_id}/{platform_key}/{credential_type}/{uuid_short}
"""
user_id = (
(os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
or "_anon"
)
safe_label = re.sub(r"[^a-zA-Z0-9_-]", "_", credential_id_or_label)[:32]
uid = uuid.uuid4().hex[:8]
return (
f"openclaw/account-manager/{user_id}/"
f"{platform_key}/{credential_type}/{safe_label}_{uid}"
)
# ---------------------------------------------------------------------------
# Write
# ---------------------------------------------------------------------------
def write_secret(storage: str, secret_ref: str, secret_value: str) -> None:
"""
Write a secret to the specified storage backend.
Raises ValueError on unsupported storage or write failure.
"""
log = get_skill_logger()
log.info(
"secret_write_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_write_success storage=none ref=%s", secret_ref)
return
elif storage_n == "env":
log.info("secret_write_success storage=env ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_write_impl(secret_ref, secret_value)
log.info(
"secret_write_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
log.error("secret_write_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
def read_secret(storage: str, secret_ref: str) -> str:
"""
Read/retrieve a secret from the specified storage backend.
Returns the secret value as a string.
Raises ValueError on unsupported storage, missing ref, or read failure.
"""
log = get_skill_logger()
log.info(
"secret_resolve_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_resolve_success storage=none")
return ""
elif storage_n == "env":
val = os.environ.get(secret_ref)
if val is None:
log.error("secret_resolve_failed env_missing ref=%s", secret_ref)
raise ValueError(
f"Environment variable '{secret_ref}' is not set (SECRET_ENV_MISSING)"
)
log.info("secret_resolve_success storage=env ref=%s", secret_ref)
return val
elif storage_n == "windows_credential":
val = _win_cred_read_impl(secret_ref)
log.info(
"secret_resolve_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return val
else:
log.error("secret_resolve_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def delete_secret(storage: str, secret_ref: str) -> None:
"""Delete a secret from the specified storage backend."""
log = get_skill_logger()
log.info(
"secret_delete_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
return
elif storage_n == "env":
log.info("secret_delete_env_nop ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_delete_impl(secret_ref)
log.info(
"secret_delete_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Windows Credential Manager implementation (ctypes)
# ---------------------------------------------------------------------------
_WINCRED_TYPE_GENERIC = 1 # CRED_TYPE_GENERIC
_WINCRED_PERSIST_LOCAL_MACHINE = 2 # CRED_PERSIST_LOCAL_MACHINE
def _win_cred_available() -> bool:
"""Check if Windows Credential Manager API is available."""
if sys.platform != "win32":
return False
try:
import ctypes # noqa: F401
return True
except ImportError:
return False
def _win_cred_unavailable() -> OSError:
return OSError(
"Windows Credential Manager is not available on this platform "
"(WINDOWS_CREDENTIAL_UNAVAILABLE)"
)
def _win_cred_write_impl(target: str, secret_value: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
secret_bytes = secret_value.encode("utf-16-le")
blob_arr = (ctypes.c_byte * len(secret_bytes)).from_buffer_copy(secret_bytes)
cred = CREDENTIALW()
cred.Type = _WINCRED_TYPE_GENERIC
cred.TargetName = target
cred.CredentialBlobSize = len(secret_bytes)
cred.CredentialBlob = ctypes.cast(blob_arr, ctypes.POINTER(ctypes.c_byte))
cred.Persist = _WINCRED_PERSIST_LOCAL_MACHINE
cred.UserName = "account-manager"
advapi32 = ctypes.windll.advapi32
if not advapi32.CredWriteW(ctypes.byref(cred), 0):
err = ctypes.get_last_error()
raise OSError(f"CredWriteW failed with error {err} (SECRET_WRITE_FAILED)")
del blob_arr, cred
def _win_cred_read_impl(target: str) -> str:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
PCREDENTIALW = ctypes.POINTER(CREDENTIALW)
advapi32 = ctypes.windll.advapi32
advapi32.CredReadW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.POINTER(PCREDENTIALW),
]
advapi32.CredReadW.restype = wintypes.BOOL
advapi32.CredFree.argtypes = [ctypes.c_void_p]
advapi32.CredFree.restype = None
ppcred = PCREDENTIALW()
if not advapi32.CredReadW(target, _WINCRED_TYPE_GENERIC, 0, ctypes.byref(ppcred)):
err = ctypes.get_last_error()
if err == 1168:
raise ValueError(
f"Credential not found in Windows Credential Manager: target={target}"
)
raise OSError(f"CredReadW failed with error {err} (SECRET_READ_FAILED)")
try:
cred = ppcred.contents
blob_size = int(cred.CredentialBlobSize)
if blob_size <= 0 or not cred.CredentialBlob:
return ""
raw = ctypes.string_at(cred.CredentialBlob, blob_size)
return raw.decode("utf-16-le")
finally:
advapi32.CredFree(ppcred)
def _win_cred_delete_impl(target: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
advapi32 = ctypes.windll.advapi32
if not advapi32.CredDeleteW(target, _WINCRED_TYPE_GENERIC, 0):
err = ctypes.get_last_error()
if err != 1168:
raise OSError(f"CredDeleteW failed with error {err}")