Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 Edge(channel 模式,无需 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)
|
||||
|
||||
306
scripts/service/secret_store.py
Normal file
306
scripts/service/secret_store.py
Normal 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}")
|
||||
Reference in New Issue
Block a user